Commit 85628be
Antonio Mika
·
2026-10-10 21:45:52 -0400 EDT
parent 2fc9766
refactor(router): serve apps with http.ServeMux and typed handlers
18 files changed,
+634,
-625
+29,
-12
| ... | ... | @@ -49,7 +49,24 @@ func generateURL(cfg *shared.ConfigSite, path string, space string) string { | |
| 49 | 49 | return fmt.Sprintf("%s/%s%s", cfg.Domain, path, query) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | func wellKnownHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 52 | + | type ApiConfig struct { | |
| 53 | + | Cfg *shared.ConfigSite | |
| 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 hc.Dbpool.HasFeatureByUser(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 { | |
| 53 | 70 | return func(w http.ResponseWriter, r *http.Request) { | |
| 54 | 71 | space := r.PathValue("space") | |
| 55 | 72 | if space == "" { |
| ... | ... | @@ -81,7 +98,7 @@ type oauth2Introspection struct { | |
| 81 | 98 | Username string `json:"username"` | |
| 82 | 99 | } | |
| 83 | 100 | ||
| 84 | - | func introspectHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 101 | + | func introspectHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 85 | 102 | return func(w http.ResponseWriter, r *http.Request) { | |
| 86 | 103 | token := r.FormValue("token") | |
| 87 | 104 | apiConfig.Cfg.Logger.Info("introspect token", "token", token) |
| ... | ... | @@ -115,7 +132,7 @@ func introspectHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 115 | 132 | } | |
| 116 | 133 | } | |
| 117 | 134 | ||
| 118 | - | func authorizeHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 135 | + | func authorizeHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 119 | 136 | return func(w http.ResponseWriter, r *http.Request) { | |
| 120 | 137 | responseType := r.URL.Query().Get("response_type") | |
| 121 | 138 | clientID := r.URL.Query().Get("client_id") |
| ... | ... | @@ -159,7 +176,7 @@ func authorizeHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 159 | 176 | } | |
| 160 | 177 | } | |
| 161 | 178 | ||
| 162 | - | func redirectHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 179 | + | func redirectHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 163 | 180 | return func(w http.ResponseWriter, r *http.Request) { | |
| 164 | 181 | token := r.FormValue("token") | |
| 165 | 182 | redirectURI := r.FormValue("redirect_uri") |
| ... | ... | @@ -195,7 +212,7 @@ type oauth2Token struct { | |
| 195 | 212 | AccessToken string `json:"access_token"` | |
| 196 | 213 | } | |
| 197 | 214 | ||
| 198 | - | func tokenHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 215 | + | func tokenHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 199 | 216 | return func(w http.ResponseWriter, r *http.Request) { | |
| 200 | 217 | token := r.FormValue("code") | |
| 201 | 218 | redirectURI := r.FormValue("redirect_uri") |
| ... | ... | @@ -234,7 +251,7 @@ type sishData struct { | |
| 234 | 251 | RemoteAddress string `json:"remote_addr"` | |
| 235 | 252 | } | |
| 236 | 253 | ||
| 237 | - | func keyHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 254 | + | func keyHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 238 | 255 | return func(w http.ResponseWriter, r *http.Request) { | |
| 239 | 256 | var data sishData | |
| 240 | 257 |
| ... | ... | @@ -308,7 +325,7 @@ func keyHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 308 | 325 | } | |
| 309 | 326 | } | |
| 310 | 327 | ||
| 311 | - | func userHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 328 | + | func userHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 312 | 329 | return func(w http.ResponseWriter, r *http.Request) { | |
| 313 | 330 | if !apiConfig.HasPrivilegedAccess(router.GetApiToken(r)) { | |
| 314 | 331 | w.WriteHeader(http.StatusForbidden) |
| ... | ... | @@ -355,7 +372,7 @@ func userHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 355 | 372 | } | |
| 356 | 373 | } | |
| 357 | 374 | ||
| 358 | - | func rssHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 375 | + | func rssHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 359 | 376 | return func(w http.ResponseWriter, r *http.Request) { | |
| 360 | 377 | apiToken := r.PathValue("token") | |
| 361 | 378 | user, err := apiConfig.Dbpool.FindUserByToken(apiToken) |
| ... | ... | @@ -388,7 +405,7 @@ func rssHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 388 | 405 | } | |
| 389 | 406 | } | |
| 390 | 407 | ||
| 391 | - | func pubkeysHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 408 | + | func pubkeysHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 392 | 409 | return func(w http.ResponseWriter, r *http.Request) { | |
| 393 | 410 | userName := r.PathValue("user") | |
| 394 | 411 | user, err := apiConfig.Dbpool.FindUserByName(userName) |
| ... | ... | @@ -457,7 +474,7 @@ type OrderEvent struct { | |
| 457 | 474 | ||
| 458 | 475 | // Status code must be 200 or else lemonsqueezy will keep retrying | |
| 459 | 476 | // https://docs.lemonsqueezy.com/help/webhooks | |
| 460 | - | func paymentWebhookHandler(apiConfig *router.ApiConfig) http.HandlerFunc { | |
| 477 | + | func paymentWebhookHandler(apiConfig *ApiConfig) http.HandlerFunc { | |
| 461 | 478 | return func(w http.ResponseWriter, r *http.Request) { | |
| 462 | 479 | dbpool := apiConfig.Dbpool | |
| 463 | 480 | logger := apiConfig.Cfg.Logger |
| ... | ... | @@ -863,7 +880,7 @@ func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger | |
| 863 | 880 | } | |
| 864 | 881 | } | |
| 865 | 882 | ||
| 866 | - | func authMux(apiConfig *router.ApiConfig) *http.ServeMux { | |
| 883 | + | func authMux(apiConfig *ApiConfig) *http.ServeMux { | |
| 867 | 884 | serverRoot, err := fs.Sub(embedFS, "public") | |
| 868 | 885 | if err != nil { | |
| 869 | 886 | panic(err) |
| ... | ... | @@ -942,7 +959,7 @@ func StartApiServer() { | |
| 942 | 959 | // monthly analytics aggregation (checks daily, runs if previous month is missing) | |
| 943 | 960 | go analyticsAggregationCron(ctx, db, logger) | |
| 944 | 961 | ||
| 945 | - | apiConfig := &router.ApiConfig{ | |
| 962 | + | apiConfig := &ApiConfig{ | |
| 946 | 963 | Cfg: cfg, | |
| 947 | 964 | Dbpool: db, | |
| 948 | 965 | } |
+2,
-2
| ... | ... | @@ -277,7 +277,7 @@ func mkpath(path string) string { | |
| 277 | 277 | return fmt.Sprintf("https://auth.pico.test%s", path) | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | - | func setupTest() *router.ApiConfig { | |
| 280 | + | func setupTest() *ApiConfig { | |
| 281 | 281 | logger := shared.CreateLogger("auth-test", false) | |
| 282 | 282 | cfg := &shared.ConfigSite{ | |
| 283 | 283 | Issuer: "auth.pico.test", |
| ... | ... | @@ -288,7 +288,7 @@ func setupTest() *router.ApiConfig { | |
| 288 | 288 | } | |
| 289 | 289 | cfg.Logger = logger | |
| 290 | 290 | db := NewAuthDb(cfg.Logger) | |
| 291 | - | apiConfig := &router.ApiConfig{ | |
| 291 | + | apiConfig := &ApiConfig{ | |
| 292 | 292 | Cfg: cfg, | |
| 293 | 293 | Dbpool: db, | |
| 294 | 294 | } |
+29,
-46
| ... | ... | @@ -12,10 +12,10 @@ import ( | |
| 12 | 12 | "github.com/prometheus/client_golang/prometheus/promhttp" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func keepAliveHandler(w http.ResponseWriter, r *http.Request) { | |
| 16 | - | dbpool := 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")) | |
| 19 | 19 | ||
| 20 | 20 | post, err := dbpool.FindPost(postID) | |
| 21 | 21 | if err != nil { |
| ... | ... | @@ -60,10 +60,10 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) { | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | - | func unsubHandler(w http.ResponseWriter, r *http.Request) { | |
| 64 | - | dbpool := 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")) | |
| 67 | 67 | ||
| 68 | 68 | post, err := dbpool.FindPost(postID) | |
| 69 | 69 | if err != nil { |
| ... | ... | @@ -97,32 +97,28 @@ func unsubHandler(w http.ResponseWriter, r *http.Request) { | |
| 97 | 97 | } | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 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 *shared.ConfigSite | |
| 102 | + | db DB | |
| 114 | 103 | } | |
| 115 | 104 | ||
| 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 | + | mux := http.NewServeMux() | |
| 108 | + | mux.HandleFunc("GET /{$}", router.CreatePageHandler(cfg, "html/marketing.page.tmpl")) | |
| 109 | + | mux.HandleFunc("GET /keep-alive/{id...}", web.keepAliveHandler) | |
| 110 | + | mux.HandleFunc("GET /unsub/{id...}", web.unsubHandler) | |
| 111 | + | mux.Handle("GET /_metrics", promhttp.Handler()) | |
| 112 | + | mux.HandleFunc("GET /main.css", router.ServeFile(cfg, "main.css", "text/css")) | |
| 113 | + | mux.HandleFunc("GET /card.png", router.ServeFile(cfg, "card.png", "image/png")) | |
| 114 | + | mux.HandleFunc("GET /favicon-16x16.png", router.ServeFile(cfg, "favicon-16x16.png", "image/png")) | |
| 115 | + | mux.HandleFunc("GET /apple-touch-icon.png", router.ServeFile(cfg, "apple-touch-icon.png", "image/png")) | |
| 116 | + | mux.HandleFunc("GET /favicon.ico", router.ServeFile(cfg, "favicon.ico", "image/x-icon")) | |
| 117 | + | mux.HandleFunc("GET /robots.txt", router.ServeFile(cfg, "robots.txt", "text/plain")) | |
| 118 | + | if cfg.Debug { | |
| 119 | + | router.CreatePProfRoutesMux(mux) | |
| 125 | 120 | } | |
| 121 | + | return mux | |
| 126 | 122 | } | |
| 127 | 123 | ||
| 128 | 124 | func StartApiServer() { |
| ... | ... | @@ -137,20 +133,7 @@ func StartApiServer() { | |
| 137 | 133 | fetcher := NewFetcher(db, cfg) | |
| 138 | 134 | go fetcher.Loop() | |
| 139 | 135 | ||
| 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) | |
| 136 | + | web := &webRouter{cfg: cfg, db: db} | |
| 154 | 137 | ||
| 155 | 138 | portStr := fmt.Sprintf(":%s", cfg.Port) | |
| 156 | 139 | logger.Info( |
| ... | ... | @@ -159,5 +142,5 @@ func StartApiServer() { | |
| 159 | 142 | "domain", cfg.Domain, | |
| 160 | 143 | ) | |
| 161 | 144 | ||
| 162 | - | logger.Error(http.ListenAndServe(portStr, router).Error()) | |
| 145 | + | logger.Error(http.ListenAndServe(portStr, web.mux()).Error()) | |
| 163 | 146 | } |
+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 := NewConfigSite("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 | + | } |
+0,
-7
| ... | ... | @@ -3,11 +3,9 @@ package feeds | |
| 3 | 3 | import ( | |
| 4 | 4 | "database/sql" | |
| 5 | 5 | "fmt" | |
| 6 | - | "net/http" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/picosh/pico/pkg/db" | |
| 9 | 8 | "github.com/picosh/pico/pkg/db/postgres" | |
| 10 | - | "github.com/picosh/pico/pkg/shared/router" | |
| 11 | 9 | ) | |
| 12 | 10 | ||
| 13 | 11 | // DB is the storage feeds uses. |
| ... | ... | @@ -104,8 +102,3 @@ func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) { | |
| 104 | 102 | } | |
| 105 | 103 | return items, nil | |
| 106 | 104 | } | |
| 107 | - | ||
| 108 | - | // getDB is the storage StartApiServer gave the router, which is a PsqlDB. | |
| 109 | - | func getDB(r *http.Request) DB { | |
| 110 | - | return router.GetDB(r).(DB) | |
| 111 | - | } |
+76,
-114
| ... | ... | @@ -5,7 +5,6 @@ import ( | |
| 5 | 5 | "html/template" | |
| 6 | 6 | "net/http" | |
| 7 | 7 | "net/url" | |
| 8 | - | "os" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/picosh/pico/pkg/db" |
| ... | ... | @@ -72,12 +71,12 @@ type HeaderTxt struct { | |
| 72 | 71 | HasLinks bool | |
| 73 | 72 | } | |
| 74 | 73 | ||
| 75 | - | func blogHandler(w http.ResponseWriter, r *http.Request) { | |
| 76 | - | username := router.GetUsernameFromRequest(r) | |
| 77 | - | dbpool := getDB(r) | |
| 78 | - | blogger := router.GetLogger(r) | |
| 74 | + | func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) { | |
| 75 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 76 | + | dbpool := web.db | |
| 77 | + | blogger := web.cfg.Logger | |
| 79 | 78 | logger := blogger.With("user", username) | |
| 80 | - | cfg := router.GetCfg(r) | |
| 79 | + | cfg := web.cfg | |
| 81 | 80 | ||
| 82 | 81 | user, err := dbpool.FindUserByName(username) | |
| 83 | 82 | if err != nil { |
| ... | ... | @@ -155,20 +154,13 @@ func GetBlogName(username string) string { | |
| 155 | 154 | return fmt.Sprintf("%s's pastes", username) | |
| 156 | 155 | } | |
| 157 | 156 | ||
| 158 | - | func postHandler(w http.ResponseWriter, r *http.Request) { | |
| 159 | - | username := router.GetUsernameFromRequest(r) | |
| 160 | - | subdomain := router.GetSubdomain(r) | |
| 161 | - | cfg := router.GetCfg(r) | |
| 157 | + | func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) { | |
| 158 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 159 | + | cfg := web.cfg | |
| 160 | + | slug, _ := url.PathUnescape(r.PathValue("slug")) | |
| 162 | 161 | ||
| 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 := getDB(r) | |
| 171 | - | blogger := router.GetLogger(r) | |
| 162 | + | dbpool := web.db | |
| 163 | + | blogger := web.cfg.Logger | |
| 172 | 164 | logger := blogger.With("slug", slug, "user", username) | |
| 173 | 165 | ||
| 174 | 166 | user, err := dbpool.FindUserByName(username) |
| ... | ... | @@ -259,20 +251,13 @@ func postHandler(w http.ResponseWriter, r *http.Request) { | |
| 259 | 251 | } | |
| 260 | 252 | } | |
| 261 | 253 | ||
| 262 | - | func postHandlerRaw(w http.ResponseWriter, r *http.Request) { | |
| 263 | - | 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 | - | } | |
| 254 | + | func (web *webRouter) postHandlerRaw(w http.ResponseWriter, r *http.Request) { | |
| 255 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 256 | + | cfg := web.cfg | |
| 257 | + | slug, _ := url.PathUnescape(r.PathValue("slug")) | |
| 273 | 258 | ||
| 274 | - | dbpool := getDB(r) | |
| 275 | - | blogger := router.GetLogger(r) | |
| 259 | + | dbpool := web.db | |
| 260 | + | blogger := web.cfg.Logger | |
| 276 | 261 | logger := blogger.With("user", username, "slug", slug) | |
| 277 | 262 | ||
| 278 | 263 | user, err := dbpool.FindUserByName(username) |
| ... | ... | @@ -299,81 +284,71 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) { | |
| 299 | 284 | } | |
| 300 | 285 | } | |
| 301 | 286 | ||
| 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 *shared.ConfigSite | |
| 289 | + | db DB | |
| 320 | 290 | } | |
| 321 | 291 | ||
| 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 | + | mux.HandleFunc("GET /main.css", router.ServeFile(cfg, "main.css", "text/css")) | |
| 295 | + | mux.HandleFunc("GET /smol.css", router.ServeFile(cfg, "smol.css", "text/css")) | |
| 296 | + | mux.HandleFunc("GET /syntax.css", router.ServeFile(cfg, "syntax.css", "text/css")) | |
| 297 | + | mux.HandleFunc("GET /card.png", router.ServeFile(cfg, "card.png", "image/png")) | |
| 298 | + | mux.HandleFunc("GET /favicon-16x16.png", router.ServeFile(cfg, "favicon-16x16.png", "image/png")) | |
| 299 | + | mux.HandleFunc("GET /apple-touch-icon.png", router.ServeFile(cfg, "apple-touch-icon.png", "image/png")) | |
| 300 | + | mux.HandleFunc("GET /favicon.ico", router.ServeFile(cfg, "favicon.ico", "image/x-icon")) | |
| 301 | + | mux.HandleFunc("GET /robots.txt", router.ServeFile(cfg, "robots.txt", "text/plain")) | |
| 302 | + | if cfg.Debug { | |
| 303 | + | router.CreatePProfRoutesMux(mux) | |
| 333 | 304 | } | |
| 334 | 305 | } | |
| 335 | 306 | ||
| 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 | - | ) | |
| 307 | + | // mainMux serves the marketing site and users' pastes below /{user}. | |
| 308 | + | func (web *webRouter) mainMux() *http.ServeMux { | |
| 309 | + | mux := http.NewServeMux() | |
| 310 | + | mux.HandleFunc("GET /{$}", router.CreatePageHandler(web.cfg, "html/marketing.page.tmpl")) | |
| 311 | + | mux.HandleFunc("GET /check", router.CheckHandler(web.cfg, web.db)) | |
| 312 | + | mux.Handle("GET /_metrics", promhttp.Handler()) | |
| 313 | + | web.static(mux) | |
| 314 | + | mux.HandleFunc("GET /{user}", web.blogHandler) | |
| 315 | + | mux.HandleFunc("GET /{user}/{slug}", web.postHandler) | |
| 316 | + | mux.HandleFunc("GET /{first}/{second}/{third}", web.rawRoute) | |
| 317 | + | return mux | |
| 318 | + | } | |
| 355 | 319 | ||
| 356 | - | return routes | |
| 320 | + | // sitesMux serves a user's pastes on their subdomain. | |
| 321 | + | func (web *webRouter) sitesMux() *http.ServeMux { | |
| 322 | + | mux := http.NewServeMux() | |
| 323 | + | mux.HandleFunc("GET /{$}", web.blogHandler) | |
| 324 | + | web.static(mux) | |
| 325 | + | mux.HandleFunc("GET /{slug}", web.postHandler) | |
| 326 | + | mux.HandleFunc("GET /{first}/{second}", web.rawRoute) | |
| 327 | + | return mux | |
| 357 | 328 | } | |
| 358 | 329 | ||
| 359 | - | func createSubdomainRoutes(staticRoutes []router.Route) []router.Route { | |
| 360 | - | routes := []router.Route{ | |
| 361 | - | router.NewRoute("GET", "/", blogHandler), | |
| 330 | + | // rawRoute serves a raw paste at .../{slug}/raw or raw/.../{slug}, with the | |
| 331 | + | // user first on the main domain. The two overlap as patterns, so a path | |
| 332 | + | // that fits both, like /raw/raw, is the first kind. | |
| 333 | + | func (web *webRouter) rawRoute(w http.ResponseWriter, r *http.Request) { | |
| 334 | + | segs := []string{r.PathValue("first"), r.PathValue("second")} | |
| 335 | + | if third := r.PathValue("third"); third != "" { | |
| 336 | + | segs = append(segs, third) | |
| 362 | 337 | } | |
| 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 | |
| 338 | + | switch { | |
| 339 | + | case segs[len(segs)-1] == "raw": | |
| 340 | + | segs = segs[:len(segs)-1] | |
| 341 | + | case segs[0] == "raw": | |
| 342 | + | segs = segs[1:] | |
| 343 | + | default: | |
| 344 | + | http.NotFound(w, r) | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | if len(segs) == 2 { | |
| 348 | + | r.SetPathValue("user", segs[0]) | |
| 349 | + | } | |
| 350 | + | r.SetPathValue("slug", segs[len(segs)-1]) | |
| 351 | + | web.postHandlerRaw(w, r) | |
| 377 | 352 | } | |
| 378 | 353 | ||
| 379 | 354 | func StartApiServer() { |
| ... | ... | @@ -386,21 +361,8 @@ func StartApiServer() { | |
| 386 | 361 | ||
| 387 | 362 | go CronDeleteExpiredPosts(cfg, db) | |
| 388 | 363 | ||
| 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) | |
| 364 | + | web := &webRouter{cfg: cfg, db: db} | |
| 365 | + | handler := router.NewHostRouter(cfg, web.mainMux(), web.sitesMux()) | |
| 404 | 366 | ||
| 405 | 367 | portStr := fmt.Sprintf(":%s", cfg.Port) | |
| 406 | 368 | logger.Info( |
| ... | ... | @@ -409,5 +371,5 @@ func StartApiServer() { | |
| 409 | 371 | "domain", cfg.Domain, | |
| 410 | 372 | ) | |
| 411 | 373 | ||
| 412 | - | logger.Error(http.ListenAndServe(portStr, router).Error()) | |
| 374 | + | logger.Error(http.ListenAndServe(portStr, handler).Error()) | |
| 413 | 375 | } |
+57,
-8
| ... | ... | @@ -1,7 +1,6 @@ | |
| 1 | 1 | package pastes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "html/template" | |
| 6 | 5 | "log/slog" | |
| 7 | 6 | "net/http" |
| ... | ... | @@ -19,13 +18,17 @@ import ( | |
| 19 | 18 | type pasteDB struct { | |
| 20 | 19 | *stub.StubDB | |
| 21 | 20 | post *db.Post | |
| 21 | + | // user and slug are the last ones asked for. | |
| 22 | + | user, slug string | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 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 | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 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 | |
| 29 | 32 | return m.post, nil | |
| 30 | 33 | } | |
| 31 | 34 |
| ... | ... | @@ -44,11 +47,11 @@ func TestLargePasteEscapesHTML(t *testing.T) { | |
| 44 | 47 | Text: text, FileSize: len(text), PublishAt: &now, UpdatedAt: &now, | |
| 45 | 48 | }, | |
| 46 | 49 | } | |
| 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, web.mainMux(), web.sitesMux()) | |
| 49 | 52 | ||
| 50 | 53 | 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)) | |
| 52 | 55 | if page.Code != http.StatusOK { | |
| 53 | 56 | t.Fatalf("page status = %d", page.Code) | |
| 54 | 57 | } |
| ... | ... | @@ -61,8 +64,54 @@ func TestLargePasteEscapesHTML(t *testing.T) { | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | 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)) | |
| 65 | 68 | if raw.Code != http.StatusOK || raw.Body.String() != text { | |
| 66 | 69 | t.Fatal("raw response does not preserve the uploaded text") | |
| 67 | 70 | } | |
| 68 | 71 | } | |
| 72 | + | ||
| 73 | + | func TestRoutes(t *testing.T) { | |
| 74 | + | t.Chdir("../../..") | |
| 75 | + | logger := slog.New(slog.DiscardHandler) | |
| 76 | + | cfg := &shared.ConfigSite{ | |
| 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, 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 | + | } |
+0,
-7
| ... | ... | @@ -2,11 +2,9 @@ package pastes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "net/http" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/picosh/pico/pkg/db" | |
| 8 | 7 | "github.com/picosh/pico/pkg/db/postgres" | |
| 9 | - | "github.com/picosh/pico/pkg/shared/router" | |
| 10 | 8 | ) | |
| 11 | 9 | ||
| 12 | 10 | // DB is the storage pastes uses. |
| ... | ... | @@ -45,8 +43,3 @@ func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) { | |
| 45 | 43 | } | |
| 46 | 44 | return posts, nil | |
| 47 | 45 | } | |
| 48 | - | ||
| 49 | - | // getDB is the storage StartApiServer gave the router, which is a PsqlDB. | |
| 50 | - | func getDB(r *http.Request) DB { | |
| 51 | - | return router.GetDB(r).(DB) | |
| 52 | - | } |
+51,
-85
| ... | ... | @@ -9,7 +9,6 @@ import ( | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/url" | |
| 12 | - | "os" | |
| 13 | 12 | "regexp" | |
| 14 | 13 | "strings" | |
| 15 | 14 | "sync" |
| ... | ... | @@ -34,41 +33,6 @@ var ( | |
| 34 | 33 | } | |
| 35 | 34 | ) | |
| 36 | 35 | ||
| 37 | - | func serveFile(file string, contentType string) http.HandlerFunc { | |
| 38 | - | return func(w http.ResponseWriter, r *http.Request) { | |
| 39 | - | logger := router.GetLogger(r) | |
| 40 | - | cfg := router.GetCfg(r) | |
| 41 | - | ||
| 42 | - | contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file))) | |
| 43 | - | if err != nil { | |
| 44 | - | logger.Error("could not read statis file", "err", err.Error()) | |
| 45 | - | http.Error(w, "file not found", 404) | |
| 46 | - | } | |
| 47 | - | w.Header().Add("Content-Type", contentType) | |
| 48 | - | ||
| 49 | - | _, err = w.Write(contents) | |
| 50 | - | if err != nil { | |
| 51 | - | logger.Error("could not write static file", "err", err.Error()) | |
| 52 | - | http.Error(w, "server error", http.StatusInternalServerError) | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func createStaticRoutes() []router.Route { | |
| 58 | - | return []router.Route{ | |
| 59 | - | router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")), | |
| 60 | - | router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")), | |
| 61 | - | router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")), | |
| 62 | - | router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")), | |
| 63 | - | router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")), | |
| 64 | - | router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")), | |
| 65 | - | router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")), | |
| 66 | - | router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")), | |
| 67 | - | router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")), | |
| 68 | - | router.NewRoute("GET", "/anim.js", serveFile("anim.js", "text/javascript")), | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | 36 | type writeFlusher struct { | |
| 73 | 37 | responseWriter http.ResponseWriter | |
| 74 | 38 | controller *http.ResponseController |
| ... | ... | @@ -84,12 +48,12 @@ func (w writeFlusher) Write(p []byte) (n int, err error) { | |
| 84 | 48 | ||
| 85 | 49 | var _ io.Writer = writeFlusher{} | |
| 86 | 50 | ||
| 87 | - | func handleSub(pubsub bool) http.HandlerFunc { | |
| 51 | + | func (web *webRouter) handleSub(pubsub bool) http.HandlerFunc { | |
| 88 | 52 | return func(w http.ResponseWriter, r *http.Request) { | |
| 89 | - | logger := router.GetLogger(r) | |
| 53 | + | logger := web.cfg.Logger | |
| 90 | 54 | ||
| 91 | 55 | clientInfo := shared.NewPicoPipeClient() | |
| 92 | - | topic, _ := url.PathUnescape(router.GetField(r, 0)) | |
| 56 | + | topic, _ := url.PathUnescape(r.PathValue("topic")) | |
| 93 | 57 | ||
| 94 | 58 | topic = cleanRegex.ReplaceAllString(topic, "") | |
| 95 | 59 |
| ... | ... | @@ -137,12 +101,12 @@ func handleSub(pubsub bool) http.HandlerFunc { | |
| 137 | 101 | } | |
| 138 | 102 | } | |
| 139 | 103 | ||
| 140 | - | func handlePub(pubsub bool) http.HandlerFunc { | |
| 104 | + | func (web *webRouter) handlePub(pubsub bool) http.HandlerFunc { | |
| 141 | 105 | return func(w http.ResponseWriter, r *http.Request) { | |
| 142 | - | logger := router.GetLogger(r) | |
| 106 | + | logger := web.cfg.Logger | |
| 143 | 107 | ||
| 144 | 108 | clientInfo := shared.NewPicoPipeClient() | |
| 145 | - | topic, _ := url.PathUnescape(router.GetField(r, 0)) | |
| 109 | + | topic, _ := url.PathUnescape(r.PathValue("topic")) | |
| 146 | 110 | ||
| 147 | 111 | topic = cleanRegex.ReplaceAllString(topic, "") | |
| 148 | 112 |
| ... | ... | @@ -281,9 +245,9 @@ func handlePub(pubsub bool) http.HandlerFunc { | |
| 281 | 245 | } | |
| 282 | 246 | } | |
| 283 | 247 | ||
| 284 | - | func handlePipe() http.HandlerFunc { | |
| 248 | + | func (web *webRouter) handlePipe() http.HandlerFunc { | |
| 285 | 249 | return func(w http.ResponseWriter, r *http.Request) { | |
| 286 | - | logger := router.GetLogger(r) | |
| 250 | + | logger := web.cfg.Logger | |
| 287 | 251 | ||
| 288 | 252 | c, err := upgrader.Upgrade(w, r, nil) | |
| 289 | 253 | if err != nil { |
| ... | ... | @@ -296,7 +260,7 @@ func handlePipe() http.HandlerFunc { | |
| 296 | 260 | }() | |
| 297 | 261 | ||
| 298 | 262 | clientInfo := shared.NewPicoPipeClient() | |
| 299 | - | topic, _ := url.PathUnescape(router.GetField(r, 0)) | |
| 263 | + | topic, _ := url.PathUnescape(r.PathValue("topic")) | |
| 300 | 264 | ||
| 301 | 265 | topic = cleanRegex.ReplaceAllString(topic, "") | |
| 302 | 266 |
| ... | ... | @@ -435,7 +399,7 @@ func handlePipe() http.HandlerFunc { | |
| 435 | 399 | ||
| 436 | 400 | func rssHandler(cfg *shared.ConfigSite, dbpool DB) http.HandlerFunc { | |
| 437 | 401 | return func(w http.ResponseWriter, r *http.Request) { | |
| 438 | - | apiToken, _ := url.PathUnescape(router.GetField(r, 0)) | |
| 402 | + | apiToken, _ := url.PathUnescape(r.PathValue("token")) | |
| 439 | 403 | user, err := dbpool.FindUserByToken(apiToken) | |
| 440 | 404 | if err != nil { | |
| 441 | 405 | cfg.Logger.Error( |
| ... | ... | @@ -470,33 +434,48 @@ func rssHandler(cfg *shared.ConfigSite, dbpool DB) http.HandlerFunc { | |
| 470 | 434 | } | |
| 471 | 435 | } | |
| 472 | 436 | ||
| 473 | - | func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpool DB) []router.Route { | |
| 474 | - | routes := []router.Route{ | |
| 475 | - | router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")), | |
| 476 | - | router.NewRoute("GET", "/check", router.CheckHandler), | |
| 477 | - | router.NewRoute("GET", "/rss/(.+)", rssHandler(cfg, dbpool)), | |
| 478 | - | router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP), | |
| 479 | - | } | |
| 480 | - | ||
| 481 | - | pipeRoutes := []router.Route{ | |
| 482 | - | router.NewRoute("GET", "/topic/(.+)", handleSub(false)), | |
| 483 | - | router.NewRoute("POST", "/topic/(.+)", handlePub(false)), | |
| 484 | - | router.NewRoute("GET", "/pubsub/(.+)", handleSub(true)), | |
| 485 | - | router.NewRoute("POST", "/pubsub/(.+)", handlePub(true)), | |
| 486 | - | router.NewRoute("GET", "/pipe/(.+)", handlePipe()), | |
| 487 | - | } | |
| 437 | + | type webRouter struct { | |
| 438 | + | cfg *shared.ConfigSite | |
| 439 | + | db DB | |
| 440 | + | } | |
| 488 | 441 | ||
| 489 | - | for _, route := range pipeRoutes { | |
| 490 | - | route.CorsEnabled = true | |
| 491 | - | routes = append(routes, route) | |
| 442 | + | func (web *webRouter) static(mux *http.ServeMux) { | |
| 443 | + | cfg := web.cfg | |
| 444 | + | mux.HandleFunc("GET /favicon-16x16.png", router.ServeFile(cfg, "favicon-16x16.png", "image/png")) | |
| 445 | + | mux.HandleFunc("GET /apple-touch-icon.png", router.ServeFile(cfg, "apple-touch-icon.png", "image/png")) | |
| 446 | + | mux.HandleFunc("GET /favicon.ico", router.ServeFile(cfg, "favicon.ico", "image/x-icon")) | |
| 447 | + | mux.HandleFunc("GET /robots.txt", router.ServeFile(cfg, "robots.txt", "text/plain")) | |
| 448 | + | mux.HandleFunc("GET /anim.js", router.ServeFile(cfg, "anim.js", "text/javascript")) | |
| 449 | + | if cfg.Debug { | |
| 450 | + | router.CreatePProfRoutesMux(mux) | |
| 492 | 451 | } | |
| 452 | + | } | |
| 493 | 453 | ||
| 494 | - | routes = append( | |
| 495 | - | routes, | |
| 496 | - | staticRoutes..., | |
| 497 | - | ) | |
| 454 | + | func (web *webRouter) mainMux() *http.ServeMux { | |
| 455 | + | mux := http.NewServeMux() | |
| 456 | + | mux.HandleFunc("GET /{$}", router.CreatePageHandler(web.cfg, "html/marketing.page.tmpl")) | |
| 457 | + | mux.HandleFunc("GET /check", router.CheckHandler(web.cfg, 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 | + | } | |
| 498 | 473 | ||
| 499 | - | 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 | |
| 500 | 479 | } | |
| 501 | 480 | ||
| 502 | 481 | func StartApiServer() { |
| ... | ... | @@ -507,15 +486,6 @@ func StartApiServer() { | |
| 507 | 486 | }() | |
| 508 | 487 | logger := cfg.Logger | |
| 509 | 488 | ||
| 510 | - | staticRoutes := createStaticRoutes() | |
| 511 | - | ||
| 512 | - | if cfg.Debug { | |
| 513 | - | staticRoutes = router.CreatePProfRoutes(staticRoutes) | |
| 514 | - | } | |
| 515 | - | ||
| 516 | - | mainRoutes := createMainRoutes(staticRoutes, cfg, db) | |
| 517 | - | subdomainRoutes := staticRoutes | |
| 518 | - | ||
| 519 | 489 | info := shared.NewPicoPipeClient() | |
| 520 | 490 | ||
| 521 | 491 | client, err := pipe.NewClient(context.Background(), logger.With("info", info), info) |
| ... | ... | @@ -541,12 +511,8 @@ func StartApiServer() { | |
| 541 | 511 | } | |
| 542 | 512 | }() | |
| 543 | 513 | ||
| 544 | - | apiConfig := &router.ApiConfig{ | |
| 545 | - | Cfg: cfg, | |
| 546 | - | Dbpool: db, | |
| 547 | - | } | |
| 548 | - | handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig) | |
| 549 | - | router := http.HandlerFunc(handler) | |
| 514 | + | web := &webRouter{cfg: cfg, db: db} | |
| 515 | + | handler := router.NewHostRouter(cfg, web.mainMux(), web.sitesMux()) | |
| 550 | 516 | ||
| 551 | 517 | portStr := fmt.Sprintf(":%s", cfg.Port) | |
| 552 | 518 | logger.Info( |
| ... | ... | @@ -555,5 +521,5 @@ func StartApiServer() { | |
| 555 | 521 | "domain", cfg.Domain, | |
| 556 | 522 | ) | |
| 557 | 523 | ||
| 558 | - | logger.Error("listen", "err", http.ListenAndServe(portStr, router).Error()) | |
| 524 | + | logger.Error("listen", "err", http.ListenAndServe(portStr, handler).Error()) | |
| 559 | 525 | } |
+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 := NewConfigSite("pipe-test") | |
| 16 | + | cfg.Logger = slog.New(slog.DiscardHandler) | |
| 17 | + | web := &webRouter{cfg: cfg, db: NewTestDB(cfg.Logger)} | |
| 18 | + | handler := router.NewHostRouter(cfg, 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 | + | } |
+1,
-0
| ... | ... | @@ -10,6 +10,7 @@ import ( | |
| 10 | 10 | ||
| 11 | 11 | // DB is the storage pipe uses. | |
| 12 | 12 | type DB interface { | |
| 13 | + | FindUserByName(name string) (*db.User, error) | |
| 13 | 14 | FindUserByToken(token string) (*db.User, error) | |
| 14 | 15 | HasFeatureByUser(userID string, feature string) bool | |
| 15 | 16 | UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error |
+129,
-159
| ... | ... | @@ -6,8 +6,8 @@ import ( | |
| 6 | 6 | "html/template" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "net/url" | |
| 9 | - | "os" | |
| 10 | 9 | "path/filepath" | |
| 10 | + | "regexp" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "time" | |
| 13 | 13 |
| ... | ... | @@ -118,11 +118,11 @@ func GetBlogName(username string) string { | |
| 118 | 118 | return fmt.Sprintf("%s's blog", username) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | - | func blogStyleHandler(w http.ResponseWriter, r *http.Request) { | |
| 122 | - | username := router.GetUsernameFromRequest(r) | |
| 123 | - | dbpool := getDB(r) | |
| 124 | - | logger := router.GetLogger(r) | |
| 125 | - | cfg := router.GetCfg(r) | |
| 121 | + | func (web *webRouter) blogStyleHandler(w http.ResponseWriter, r *http.Request) { | |
| 122 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 123 | + | dbpool := web.db | |
| 124 | + | logger := web.cfg.Logger | |
| 125 | + | cfg := web.cfg | |
| 126 | 126 | ||
| 127 | 127 | user, err := dbpool.FindUserByName(username) | |
| 128 | 128 | if err != nil { |
| ... | ... | @@ -148,11 +148,11 @@ func blogStyleHandler(w http.ResponseWriter, r *http.Request) { | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | - | func blogHandler(w http.ResponseWriter, r *http.Request) { | |
| 152 | - | username := router.GetUsernameFromRequest(r) | |
| 153 | - | dbpool := getDB(r) | |
| 154 | - | logger := router.GetLogger(r) | |
| 155 | - | cfg := router.GetCfg(r) | |
| 151 | + | func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) { | |
| 152 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 153 | + | dbpool := web.db | |
| 154 | + | logger := web.cfg.Logger | |
| 155 | + | cfg := web.cfg | |
| 156 | 156 | ||
| 157 | 157 | user, err := dbpool.FindUserByName(username) | |
| 158 | 158 | if err != nil { |
| ... | ... | @@ -277,21 +277,14 @@ func blogHandler(w http.ResponseWriter, r *http.Request) { | |
| 277 | 277 | } | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | - | func postRawHandler(w http.ResponseWriter, r *http.Request) { | |
| 281 | - | 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 | + | func (web *webRouter) postRawHandler(w http.ResponseWriter, r *http.Request) { | |
| 281 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 282 | + | cfg := web.cfg | |
| 283 | + | slug, _ := url.PathUnescape(r.PathValue("slug")) | |
| 291 | 284 | slug = strings.TrimSuffix(slug, "/") | |
| 292 | 285 | ||
| 293 | - | dbpool := getDB(r) | |
| 294 | - | logger := router.GetLogger(r) | |
| 286 | + | dbpool := web.db | |
| 287 | + | logger := web.cfg.Logger | |
| 295 | 288 | logger = logger.With("slug", slug) | |
| 296 | 289 | ||
| 297 | 290 | user, err := dbpool.FindUserByName(username) |
| ... | ... | @@ -318,11 +311,11 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) { | |
| 318 | 311 | } | |
| 319 | 312 | } | |
| 320 | 313 | ||
| 321 | - | func robotsHandler(w http.ResponseWriter, r *http.Request) { | |
| 322 | - | username := router.GetUsernameFromRequest(r) | |
| 323 | - | cfg := router.GetCfg(r) | |
| 324 | - | dbpool := getDB(r) | |
| 325 | - | logger := router.GetLogger(r) | |
| 314 | + | func (web *webRouter) robotsHandler(w http.ResponseWriter, r *http.Request) { | |
| 315 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 316 | + | cfg := web.cfg | |
| 317 | + | dbpool := web.db | |
| 318 | + | logger := web.cfg.Logger | |
| 326 | 319 | user, err := dbpool.FindUserByName(username) | |
| 327 | 320 | if err != nil { | |
| 328 | 321 | logger.Info("blog not found", "user", username) |
| ... | ... | @@ -344,21 +337,14 @@ func robotsHandler(w http.ResponseWriter, r *http.Request) { | |
| 344 | 337 | } | |
| 345 | 338 | } | |
| 346 | 339 | ||
| 347 | - | func postHandler(w http.ResponseWriter, r *http.Request) { | |
| 348 | - | 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 | + | func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) { | |
| 341 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 342 | + | cfg := web.cfg | |
| 343 | + | slug, _ := url.PathUnescape(r.PathValue("slug")) | |
| 358 | 344 | slug = strings.TrimSuffix(slug, "/") | |
| 359 | 345 | ||
| 360 | - | dbpool := getDB(r) | |
| 361 | - | logger := router.GetLogger(r) | |
| 346 | + | dbpool := web.db | |
| 347 | + | logger := web.cfg.Logger | |
| 362 | 348 | ||
| 363 | 349 | user, err := dbpool.FindUserByName(username) | |
| 364 | 350 | if err != nil { |
| ... | ... | @@ -547,10 +533,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) { | |
| 547 | 533 | } | |
| 548 | 534 | } | |
| 549 | 535 | ||
| 550 | - | func readHandler(w http.ResponseWriter, r *http.Request) { | |
| 551 | - | dbpool := getDB(r) | |
| 552 | - | logger := router.GetLogger(r) | |
| 553 | - | cfg := router.GetCfg(r) | |
| 536 | + | func (web *webRouter) readHandler(w http.ResponseWriter, r *http.Request) { | |
| 537 | + | dbpool := web.db | |
| 538 | + | logger := web.cfg.Logger | |
| 539 | + | cfg := web.cfg | |
| 554 | 540 | ||
| 555 | 541 | posts, err := dbpool.FindPopularPosts() | |
| 556 | 542 | if err != nil { |
| ... | ... | @@ -595,11 +581,11 @@ func readHandler(w http.ResponseWriter, r *http.Request) { | |
| 595 | 581 | } | |
| 596 | 582 | } | |
| 597 | 583 | ||
| 598 | - | func rssBlogHandler(w http.ResponseWriter, r *http.Request) { | |
| 599 | - | username := router.GetUsernameFromRequest(r) | |
| 600 | - | dbpool := getDB(r) | |
| 601 | - | logger := router.GetLogger(r) | |
| 602 | - | cfg := router.GetCfg(r) | |
| 584 | + | func (web *webRouter) rssBlogHandler(w http.ResponseWriter, r *http.Request) { | |
| 585 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 586 | + | dbpool := web.db | |
| 587 | + | logger := web.cfg.Logger | |
| 588 | + | cfg := web.cfg | |
| 603 | 589 | ||
| 604 | 590 | user, err := dbpool.FindUserByName(username) | |
| 605 | 591 | if err != nil { |
| ... | ... | @@ -756,10 +742,10 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) { | |
| 756 | 742 | } | |
| 757 | 743 | } | |
| 758 | 744 | ||
| 759 | - | func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 760 | - | dbpool := getDB(r) | |
| 761 | - | logger := router.GetLogger(r) | |
| 762 | - | cfg := router.GetCfg(r) | |
| 745 | + | func (web *webRouter) rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 746 | + | dbpool := web.db | |
| 747 | + | logger := web.cfg.Logger | |
| 748 | + | cfg := web.cfg | |
| 763 | 749 | ||
| 764 | 750 | posts, err := dbpool.FindPopularPosts() | |
| 765 | 751 | if err != nil { |
| ... | ... | @@ -857,63 +843,11 @@ func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 857 | 843 | } | |
| 858 | 844 | } | |
| 859 | 845 | ||
| 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 := getDB(r) | |
| 916 | - | username := router.GetUsernameFromRequest(r) | |
| 846 | + | func (web *webRouter) imgRequest(w http.ResponseWriter, r *http.Request) { | |
| 847 | + | logger := web.cfg.Logger | |
| 848 | + | st := web.storage | |
| 849 | + | dbpool := web.db | |
| 850 | + | username := router.GetUsernameFromRequest(r, web.cfg) | |
| 917 | 851 | user, err := dbpool.FindUserByName(username) | |
| 918 | 852 | if err != nil { | |
| 919 | 853 | logger.Error("could not find user", "username", username) |
| ... | ... | @@ -922,8 +856,8 @@ func imgRequest(w http.ResponseWriter, r *http.Request) { | |
| 922 | 856 | } | |
| 923 | 857 | logger = shared.LoggerWithUser(logger, user) | |
| 924 | 858 | ||
| 925 | - | rawname := router.GetField(r, 0) | |
| 926 | - | imgOpts := router.GetField(r, 1) | |
| 859 | + | rawname := r.PathValue("file") | |
| 860 | + | imgOpts := r.PathValue("opts") | |
| 927 | 861 | // we place all prose images inside a "prose" folder | |
| 928 | 862 | fname := filepath.Join("/prose", rawname) | |
| 929 | 863 |
| ... | ... | @@ -946,36 +880,86 @@ func imgRequest(w http.ResponseWriter, r *http.Request) { | |
| 946 | 880 | imgproxy.ServeHTTP(w, r) | |
| 947 | 881 | } | |
| 948 | 882 | ||
| 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 | - | ) | |
| 883 | + | type webRouter struct { | |
| 884 | + | cfg *shared.ConfigSite | |
| 885 | + | db DB | |
| 886 | + | storage storage.StorageServe | |
| 887 | + | } | |
| 966 | 888 | ||
| 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 | - | ) | |
| 889 | + | func (web *webRouter) static(mux *http.ServeMux) { | |
| 890 | + | cfg := web.cfg | |
| 891 | + | mux.HandleFunc("GET /read.css", router.ServeFile(cfg, "read.css", "text/css")) | |
| 892 | + | mux.HandleFunc("GET /reset.css", router.ServeFile(cfg, "reset.css", "text/css")) | |
| 893 | + | mux.HandleFunc("GET /syntax.css", router.ServeFile(cfg, "syntax.css", "text/css")) | |
| 894 | + | mux.HandleFunc("GET /card.png", router.ServeFile(cfg, "card.png", "image/png")) | |
| 895 | + | mux.HandleFunc("GET /favicon-16x16.png", router.ServeFile(cfg, "favicon-16x16.png", "image/png")) | |
| 896 | + | mux.HandleFunc("GET /favicon.ico", router.ServeFile(cfg, "favicon.ico", "image/x-icon")) | |
| 897 | + | if cfg.Debug { | |
| 898 | + | router.CreatePProfRoutesMux(mux) | |
| 899 | + | } | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | // mainMux serves the discover feed and site pages. | |
| 903 | + | func (web *webRouter) mainMux() *http.ServeMux { | |
| 904 | + | mux := http.NewServeMux() | |
| 905 | + | mux.HandleFunc("GET /{$}", web.readHandler) | |
| 906 | + | mux.HandleFunc("GET /read", web.readHandler) | |
| 907 | + | mux.HandleFunc("GET /check", router.CheckHandler(web.cfg, web.db)) | |
| 908 | + | mux.HandleFunc("GET /rss", web.rssHandler) | |
| 909 | + | mux.HandleFunc("GET /rss.atom", web.rssHandler) | |
| 910 | + | mux.Handle("GET /_metrics", promhttp.Handler()) | |
| 911 | + | mux.HandleFunc("GET /robots.txt", router.ServeFile(web.cfg, "robots.txt", "text/plain")) | |
| 912 | + | web.static(mux) | |
| 913 | + | return mux | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | // sitesMux serves a user's blog on their subdomain or custom domain. | |
| 917 | + | func (web *webRouter) sitesMux() *http.ServeMux { | |
| 918 | + | mux := http.NewServeMux() | |
| 919 | + | mux.HandleFunc("GET /{$}", web.blogHandler) | |
| 920 | + | mux.HandleFunc("GET /_styles.css", web.blogStyleHandler) | |
| 921 | + | mux.HandleFunc("GET /robots.txt", web.robotsHandler) | |
| 922 | + | for _, feed := range []string{"/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/atom", "/blog/index.xml"} { | |
| 923 | + | mux.HandleFunc("GET "+feed, web.rssBlogHandler) | |
| 924 | + | } | |
| 925 | + | web.static(mux) | |
| 926 | + | mux.HandleFunc("GET /raw/{slug...}", web.postRawHandler) | |
| 927 | + | mux.HandleFunc("GET /{path...}", web.pathRoute) | |
| 928 | + | return mux | |
| 929 | + | } | |
| 977 | 930 | ||
| 978 | - | return routes | |
| 931 | + | var ( | |
| 932 | + | imgWithOpts = regexp.MustCompile(`^(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)$`) | |
| 933 | + | imgPath = regexp.MustCompile(`^.+\.(?:jpg|jpeg|png|gif|webp|svg|ico)$`) | |
| 934 | + | ) | |
| 935 | + | ||
| 936 | + | // pathRoute serves the site paths ServeMux patterns can't tell apart: a | |
| 937 | + | // post's markdown, an image with or without processing options, and a post. | |
| 938 | + | func (web *webRouter) pathRoute(w http.ResponseWriter, r *http.Request) { | |
| 939 | + | path := r.PathValue("path") | |
| 940 | + | for _, ext := range []string{".md", ".lxt"} { | |
| 941 | + | if slug, ok := strings.CutSuffix(path, ext); ok && slug != "" { | |
| 942 | + | r.SetPathValue("slug", slug) | |
| 943 | + | web.postRawHandler(w, r) | |
| 944 | + | return | |
| 945 | + | } | |
| 946 | + | } | |
| 947 | + | if m := imgWithOpts.FindStringSubmatch(path); m != nil { | |
| 948 | + | r.SetPathValue("file", m[1]) | |
| 949 | + | r.SetPathValue("opts", m[2]) | |
| 950 | + | web.imgRequest(w, r) | |
| 951 | + | return | |
| 952 | + | } | |
| 953 | + | if imgPath.MatchString(path) { | |
| 954 | + | r.SetPathValue("file", path) | |
| 955 | + | web.imgRequest(w, r) | |
| 956 | + | return | |
| 957 | + | } | |
| 958 | + | if slug, ok := strings.CutSuffix(path, ".html"); ok && slug != "" { | |
| 959 | + | path = slug | |
| 960 | + | } | |
| 961 | + | r.SetPathValue("slug", path) | |
| 962 | + | web.postHandler(w, r) | |
| 979 | 963 | } | |
| 980 | 964 | ||
| 981 | 965 | func StartApiServer() { |
| ... | ... | @@ -993,22 +977,8 @@ func StartApiServer() { | |
| 993 | 977 | return | |
| 994 | 978 | } | |
| 995 | 979 | ||
| 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) | |
| 980 | + | web := &webRouter{cfg: cfg, db: dbpool, storage: st} | |
| 981 | + | handler := router.NewHostRouter(cfg, web.mainMux(), web.sitesMux()) | |
| 1012 | 982 | ||
| 1013 | 983 | portStr := fmt.Sprintf(":%s", cfg.Port) | |
| 1014 | 984 | logger.Info( |
| ... | ... | @@ -1017,5 +987,5 @@ func StartApiServer() { | |
| 1017 | 987 | "domain", cfg.Domain, | |
| 1018 | 988 | ) | |
| 1019 | 989 | ||
| 1020 | - | logger.Error(http.ListenAndServe(portStr, router).Error()) | |
| 990 | + | logger.Error(http.ListenAndServe(portStr, handler).Error()) | |
| 1021 | 991 | } |
+69,
-0
| ... | ... | @@ -0,0 +1,69 @@ | |
| 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/filehandlers" | |
| 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 := NewConfigSite("prose-test") | |
| 24 | + | cfg.Logger = logger | |
| 25 | + | post := filehandlers.NewPost("hello.md", []byte("# hello")) | |
| 26 | + | post.UserID, post.Space, post.Title = user.ID, cfg.Space, "Hello" | |
| 27 | + | if _, err := posts.InsertPost(post); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | st, err := storage.NewStorageMemory(map[string]map[string]string{}) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | web := &webRouter{cfg: cfg, db: posts, storage: st} | |
| 35 | + | handler := router.NewHostRouter(cfg, web.mainMux(), web.sitesMux()) | |
| 36 | + | ||
| 37 | + | site := "https://tester." + cfg.Domain | |
| 38 | + | for _, tt := range []struct { | |
| 39 | + | url string | |
| 40 | + | status int | |
| 41 | + | // body is what the response holds, "" for anything. | |
| 42 | + | body string | |
| 43 | + | // exact means the response is body and nothing else. | |
| 44 | + | exact bool | |
| 45 | + | }{ | |
| 46 | + | {url: site + "/hello", status: http.StatusOK, body: "<html"}, | |
| 47 | + | {url: site + "/hello.html", status: http.StatusOK, body: "<html"}, | |
| 48 | + | {url: site + "/hello.md", status: http.StatusOK, body: "# hello"}, | |
| 49 | + | {url: site + "/hello.lxt", status: http.StatusOK, body: "# hello"}, | |
| 50 | + | {url: site + "/raw/hello", status: http.StatusOK, body: "# hello"}, | |
| 51 | + | {url: site + "/missing", status: http.StatusNotFound}, | |
| 52 | + | // Images are served from the user's bucket, which they don't have. | |
| 53 | + | {url: site + "/cat.png", status: http.StatusUnprocessableEntity, body: "bucket does not exist"}, | |
| 54 | + | {url: site + "/cat.png/s:500", status: http.StatusUnprocessableEntity, body: "bucket does not exist"}, | |
| 55 | + | // A site serves its own robots.txt, which this user hasn't uploaded. | |
| 56 | + | {url: site + "/robots.txt", status: http.StatusOK, exact: true}, | |
| 57 | + | {url: "https://" + cfg.Domain + "/robots.txt", status: http.StatusOK, body: "User-agent"}, | |
| 58 | + | {url: site + "/read.css", status: http.StatusOK}, | |
| 59 | + | {url: "https://" + cfg.Domain + "/read.css", status: http.StatusOK}, | |
| 60 | + | {url: "https://" + cfg.Domain + "/hello", status: http.StatusNotFound}, | |
| 61 | + | } { | |
| 62 | + | res := httptest.NewRecorder() | |
| 63 | + | handler.ServeHTTP(res, httptest.NewRequest(http.MethodGet, tt.url, nil)) | |
| 64 | + | got := res.Body.String() | |
| 65 | + | if res.Code != tt.status || !strings.Contains(got, tt.body) || (tt.exact && got != tt.body) { | |
| 66 | + | t.Errorf("%s = %d %.80q, want %d %q", tt.url, res.Code, got, tt.status, tt.body) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } |
+0,
-7
| ... | ... | @@ -3,7 +3,6 @@ package prose | |
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "math" | |
| 6 | - | "net/http" | |
| 7 | 6 | "sync" | |
| 8 | 7 | "time" | |
| 9 | 8 |
| ... | ... | @@ -11,7 +10,6 @@ import ( | |
| 11 | 10 | "github.com/jmoiron/sqlx" | |
| 12 | 11 | "github.com/picosh/pico/pkg/db" | |
| 13 | 12 | "github.com/picosh/pico/pkg/db/postgres" | |
| 14 | - | "github.com/picosh/pico/pkg/shared/router" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| 17 | 15 | // DB is the storage prose uses. |
| ... | ... | @@ -335,8 +333,3 @@ func (me *PsqlDB) FindPopularTags(space string) ([]string, error) { | |
| 335 | 333 | } | |
| 336 | 334 | return tags, nil | |
| 337 | 335 | } | |
| 338 | - | ||
| 339 | - | // getDB is the storage StartApiServer gave the router, which is a PsqlDB. | |
| 340 | - | func getDB(r *http.Request) DB { | |
| 341 | - | return router.GetDB(r).(DB) | |
| 342 | - | } |
| ... | ... | @@ -131,7 +131,9 @@ func CleanHost(raw string) (string, error) { | |
| 131 | 131 | ||
| 132 | 132 | var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled") | |
| 133 | 133 | ||
| 134 | - | func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool db.DB, secret string) error { | |
| 134 | + | func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool interface { | |
| 135 | + | HasFeatureByUser(userID string, feature string) bool | |
| 136 | + | }, secret string) error { | |
| 135 | 137 | if visit.PostID != "" { | |
| 136 | 138 | if !dbpool.HasFeatureByUser(visit.UserID, "plus") && !dbpool.HasFeatureByUser(visit.UserID, "prose") { | |
| 137 | 139 | return ErrAnalyticsDisabled |
| ... | ... | @@ -7,6 +7,7 @@ import ( | |
| 7 | 7 | "os" | |
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | + | "github.com/picosh/pico/pkg/db" | |
| 10 | 11 | "github.com/picosh/pico/pkg/shared" | |
| 11 | 12 | ) | |
| 12 | 13 |
| ... | ... | @@ -40,43 +41,44 @@ func UnauthorizedHandler(w http.ResponseWriter, r *http.Request) { | |
| 40 | 41 | http.Error(w, "You do not have access to this site", http.StatusUnauthorized) | |
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | - | func CheckHandler(w http.ResponseWriter, r *http.Request) { | |
| 44 | - | dbpool := GetDB(r) | |
| 45 | - | cfg := GetCfg(r) | |
| 46 | - | ||
| 47 | - | if cfg.IsCustomdomains() { | |
| 48 | - | hostDomain := r.URL.Query().Get("domain") | |
| 49 | - | appDomain := strings.Split(cfg.Domain, ":")[0] | |
| 50 | - | ||
| 51 | - | if !strings.Contains(hostDomain, appDomain) { | |
| 52 | - | subdomain := GetCustomDomain(hostDomain, cfg.Space) | |
| 53 | - | if subdomain != "" { | |
| 54 | - | u, err := dbpool.FindUserByName(subdomain) | |
| 55 | - | if u != nil && err == nil { | |
| 56 | - | w.WriteHeader(http.StatusOK) | |
| 57 | - | return | |
| 44 | + | // CheckHandler reports whether a custom domain points at a user's site. | |
| 45 | + | func CheckHandler(cfg *shared.ConfigSite, users interface { | |
| 46 | + | FindUserByName(name string) (*db.User, error) | |
| 47 | + | }) http.HandlerFunc { | |
| 48 | + | return func(w http.ResponseWriter, r *http.Request) { | |
| 49 | + | if cfg.IsCustomdomains() { | |
| 50 | + | hostDomain := r.URL.Query().Get("domain") | |
| 51 | + | appDomain := strings.Split(cfg.Domain, ":")[0] | |
| 52 | + | ||
| 53 | + | if !strings.Contains(hostDomain, appDomain) { | |
| 54 | + | subdomain := GetCustomDomain(hostDomain, cfg.Space) | |
| 55 | + | if subdomain != "" { | |
| 56 | + | u, err := users.FindUserByName(subdomain) | |
| 57 | + | if u != nil && err == nil { | |
| 58 | + | w.WriteHeader(http.StatusOK) | |
| 59 | + | return | |
| 60 | + | } | |
| 58 | 61 | } | |
| 59 | 62 | } | |
| 60 | 63 | } | |
| 61 | - | } | |
| 62 | 64 | ||
| 63 | - | w.WriteHeader(http.StatusNotFound) | |
| 65 | + | w.WriteHeader(http.StatusNotFound) | |
| 66 | + | } | |
| 64 | 67 | } | |
| 65 | 68 | ||
| 66 | - | func GetUsernameFromRequest(r *http.Request) string { | |
| 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, cfg *shared.ConfigSite) string { | |
| 67 | 72 | subdomain := GetSubdomain(r) | |
| 68 | - | cfg := GetCfg(r) | |
| 69 | - | ||
| 70 | 73 | if !cfg.IsSubdomains() || subdomain == "" { | |
| 71 | - | return GetField(r, 0) | |
| 74 | + | return r.PathValue("user") | |
| 72 | 75 | } | |
| 73 | 76 | return subdomain | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | - | func ServeFile(file string, contentType string) http.HandlerFunc { | |
| 79 | + | func ServeFile(cfg *shared.ConfigSite, file string, contentType string) http.HandlerFunc { | |
| 77 | 80 | return func(w http.ResponseWriter, r *http.Request) { | |
| 78 | - | logger := GetLogger(r) | |
| 79 | - | cfg := GetCfg(r) | |
| 81 | + | logger := cfg.Logger | |
| 80 | 82 | ||
| 81 | 83 | contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file))) | |
| 82 | 84 | if err != nil { |
| ... | ... | @@ -128,10 +130,9 @@ func RenderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Templ | |
| 128 | 130 | return ts, nil | |
| 129 | 131 | } | |
| 130 | 132 | ||
| 131 | - | func CreatePageHandler(fname string) http.HandlerFunc { | |
| 133 | + | func CreatePageHandler(cfg *shared.ConfigSite, fname string) http.HandlerFunc { | |
| 132 | 134 | return func(w http.ResponseWriter, r *http.Request) { | |
| 133 | - | logger := GetLogger(r) | |
| 134 | - | cfg := GetCfg(r) | |
| 135 | + | logger := cfg.Logger | |
| 135 | 136 | ts, err := RenderTemplate(cfg, []string{cfg.StaticPath(fname)}) | |
| 136 | 137 | ||
| 137 | 138 | if err != nil { |
| ... | ... | @@ -4,124 +4,41 @@ import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | "log/slog" | |
| 8 | 7 | "net" | |
| 9 | 8 | "net/http" | |
| 10 | 9 | "net/http/pprof" | |
| 11 | - | "regexp" | |
| 12 | 10 | "strings" | |
| 13 | 11 | "time" | |
| 14 | 12 | ||
| 15 | 13 | "github.com/hashicorp/golang-lru/v2/expirable" | |
| 16 | - | "github.com/picosh/pico/pkg/db" | |
| 17 | 14 | "github.com/picosh/pico/pkg/shared" | |
| 18 | - | "github.com/picosh/pico/pkg/storage" | |
| 19 | 15 | ) | |
| 20 | 16 | ||
| 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. | |
| 52 | 18 | 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) | |
| 75 | 25 | } | |
| 76 | - | return hc.Dbpool.HasFeatureByUser(user.ID, "auth") | |
| 77 | 26 | } | |
| 78 | 27 | ||
| 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 { | |
| 93 | 30 | 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) | |
| 122 | 33 | } | |
| 123 | 34 | } | |
| 124 | 35 | ||
| 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 | + | ||
| 125 | 42 | func GetSubdomainFromRequest(r *http.Request, domain, space string) string { | |
| 126 | 43 | hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0]) | |
| 127 | 44 | appDomain := strings.ToLower(strings.Split(domain, ":")[0]) |
| ... | ... | @@ -139,61 +56,31 @@ func GetSubdomainFromRequest(r *http.Request, domain, space string) string { | |
| 139 | 56 | return "" | |
| 140 | 57 | } | |
| 141 | 58 | ||
| 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 | - | } | |
| 59 | + | // NewHostRouter serves requests for users' sites, on subdomains and custom | |
| 60 | + | // domains, with sites, and the rest with main. GetSubdomain returns the | |
| 61 | + | // site's name. A nil sites serves everything with main. | |
| 62 | + | func NewHostRouter(cfg *shared.ConfigSite, main, sites http.Handler) http.Handler { | |
| 63 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 64 | + | if sites == nil { | |
| 65 | + | main.ServeHTTP(w, r) | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | subdomain := GetSubdomainFromRequest(r, cfg.Domain, cfg.Space) | |
| 69 | + | if subdomain == "" { | |
| 70 | + | main.ServeHTTP(w, r) | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | ctx := context.WithValue(r.Context(), CtxSubdomainKey{}, subdomain) | |
| 74 | + | sites.ServeHTTP(w, r.WithContext(ctx)) | |
| 75 | + | }) | |
| 161 | 76 | } | |
| 162 | 77 | ||
| 163 | - | type ctxDBKey struct{} | |
| 164 | - | type ctxStorageKey struct{} | |
| 165 | - | type ctxLoggerKey struct{} | |
| 166 | - | type ctxCfg struct{} | |
| 167 | - | ||
| 168 | 78 | 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) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | func GetStorage(r *http.Request) storage.StorageServe { | |
| 184 | - | return r.Context().Value(ctxStorageKey{}).(storage.StorageServe) | |
| 185 | - | } | |
| 186 | - | ||
| 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] | |
| 193 | - | } | |
| 194 | 79 | ||
| 80 | + | // GetSubdomain is the site a request is for, or "" for the main domain. | |
| 195 | 81 | func GetSubdomain(r *http.Request) string { | |
| 196 | - | return r.Context().Value(CtxSubdomainKey{}).(string) | |
| 82 | + | subdomain, _ := r.Context().Value(CtxSubdomainKey{}).(string) | |
| 83 | + | return subdomain | |
| 197 | 84 | } | |
| 198 | 85 | ||
| 199 | 86 | var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout) |
| ... | ... | @@ -3,7 +3,11 @@ package router | |
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "net" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 6 | 8 | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/picosh/pico/pkg/shared" | |
| 7 | 11 | ) | |
| 8 | 12 | ||
| 9 | 13 | func TestGetCustomDomainCachesMisses(t *testing.T) { |
| ... | ... | @@ -50,3 +54,40 @@ func TestGetCustomDomainCachesMisses(t *testing.T) { | |
| 50 | 54 | t.Fatalf("lookup after a transient failure got %q", got) | |
| 51 | 55 | } | |
| 52 | 56 | } | |
| 57 | + | ||
| 58 | + | func TestNewHostRouter(t *testing.T) { | |
| 59 | + | cfg := &shared.ConfigSite{Domain: "pico.test", Space: "prose"} | |
| 60 | + | serve := func(name string) http.Handler { | |
| 61 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 62 | + | _, _ = w.Write([]byte(name + ":" + GetSubdomain(r))) | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | get := func(h http.Handler, url string) string { | |
| 66 | + | res := httptest.NewRecorder() | |
| 67 | + | h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, url, nil)) | |
| 68 | + | return res.Body.String() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | h := NewHostRouter(cfg, serve("main"), serve("sites")) | |
| 72 | + | if got := get(h, "https://pico.test/"); got != "main:" { | |
| 73 | + | t.Errorf("main domain = %q", got) | |
| 74 | + | } | |
| 75 | + | if got := get(h, "https://tester.pico.test/"); got != "sites:tester" { | |
| 76 | + | t.Errorf("subdomain = %q", got) | |
| 77 | + | } | |
| 78 | + | if got := get(NewHostRouter(cfg, serve("main"), nil), "https://tester.pico.test/"); got != "main:" { | |
| 79 | + | t.Errorf("subdomain without sites = %q", got) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestCreatePProfRoutesMux(t *testing.T) { | |
| 84 | + | mux := http.NewServeMux() | |
| 85 | + | CreatePProfRoutesMux(mux) | |
| 86 | + | for _, path := range []string{"/debug/pprof/", "/debug/pprof/heap", "/debug/pprof/cmdline"} { | |
| 87 | + | res := httptest.NewRecorder() | |
| 88 | + | mux.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil)) | |
| 89 | + | if res.Code != http.StatusOK { | |
| 90 | + | t.Errorf("%s = %d", path, res.Code) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } |