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 {
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 *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 {
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)
......@@ -942,7 +959,7 @@ func StartApiServer() {
942959 // monthly analytics aggregation (checks daily, runs if previous month is missing)
943960 go analyticsAggregationCron(ctx, db, logger)
944961
945- apiConfig := &router.ApiConfig{
962+ apiConfig := &ApiConfig{
946963 Cfg: cfg,
947964 Dbpool: db,
948965 }
+2, -2
......@@ -277,7 +277,7 @@ func mkpath(path string) string {
277277 return fmt.Sprintf("https://auth.pico.test%s", path)
278278 }
279279
280-func setupTest() *router.ApiConfig {
280+func setupTest() *ApiConfig {
281281 logger := shared.CreateLogger("auth-test", false)
282282 cfg := &shared.ConfigSite{
283283 Issuer: "auth.pico.test",
......@@ -288,7 +288,7 @@ func setupTest() *router.ApiConfig {
288288 }
289289 cfg.Logger = logger
290290 db := NewAuthDb(cfg.Logger)
291- apiConfig := &router.ApiConfig{
291+ apiConfig := &ApiConfig{
292292 Cfg: cfg,
293293 Dbpool: db,
294294 }
+29, -46
......@@ -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 := 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 := 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,32 +97,28 @@ 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 *shared.ConfigSite
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+ 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)
125120 }
121+ return mux
126122 }
127123
128124 func StartApiServer() {
......@@ -137,20 +133,7 @@ func StartApiServer() {
137133 fetcher := NewFetcher(db, cfg)
138134 go fetcher.Loop()
139135
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}
154137
155138 portStr := fmt.Sprintf(":%s", cfg.Port)
156139 logger.Info(
......@@ -159,5 +142,5 @@ func StartApiServer() {
159142 "domain", cfg.Domain,
160143 )
161144
162- logger.Error(http.ListenAndServe(portStr, router).Error())
145+ logger.Error(http.ListenAndServe(portStr, web.mux()).Error())
163146 }
+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
33 import (
44 "database/sql"
55 "fmt"
6- "net/http"
76
87 "github.com/picosh/pico/pkg/db"
98 "github.com/picosh/pico/pkg/db/postgres"
10- "github.com/picosh/pico/pkg/shared/router"
119 )
1210
1311 // DB is the storage feeds uses.
......@@ -104,8 +102,3 @@ func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
104102 }
105103 return items, nil
106104 }
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 (
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) {
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
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 {
......@@ -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) {
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"))
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 := 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)
......@@ -259,20 +251,13 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
259251 }
260252 }
261253
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"))
273258
274- dbpool := 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,81 +284,71 @@ 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 *shared.ConfigSite
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+ 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)
333304 }
334305 }
335306
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+}
355319
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
357328 }
358329
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)
362337 }
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)
377352 }
378353
379354 func StartApiServer() {
......@@ -386,21 +361,8 @@ func StartApiServer() {
386361
387362 go CronDeleteExpiredPosts(cfg, db)
388363
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())
404366
405367 portStr := fmt.Sprintf(":%s", cfg.Port)
406368 logger.Info(
......@@ -409,5 +371,5 @@ func StartApiServer() {
409371 "domain", cfg.Domain,
410372 )
411373
412- logger.Error(http.ListenAndServe(portStr, router).Error())
374+ logger.Error(http.ListenAndServe(portStr, handler).Error())
413375 }
+57, -8
......@@ -1,7 +1,6 @@
11 package pastes
22
33 import (
4- "context"
54 "html/template"
65 "log/slog"
76 "net/http"
......@@ -19,13 +18,17 @@ 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
......@@ -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, 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 := &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
22
33 import (
44 "fmt"
5- "net/http"
65
76 "github.com/picosh/pico/pkg/db"
87 "github.com/picosh/pico/pkg/db/postgres"
9- "github.com/picosh/pico/pkg/shared/router"
108 )
119
1210 // DB is the storage pastes uses.
......@@ -45,8 +43,3 @@ func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
4543 }
4644 return posts, nil
4745 }
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 (
99 "io"
1010 "net/http"
1111 "net/url"
12- "os"
1312 "regexp"
1413 "strings"
1514 "sync"
......@@ -34,41 +33,6 @@ var (
3433 }
3534 )
3635
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-
7236 type writeFlusher struct {
7337 responseWriter http.ResponseWriter
7438 controller *http.ResponseController
......@@ -84,12 +48,12 @@ func (w writeFlusher) Write(p []byte) (n int, err error) {
8448
8549 var _ io.Writer = writeFlusher{}
8650
87-func handleSub(pubsub bool) http.HandlerFunc {
51+func (web *webRouter) handleSub(pubsub bool) http.HandlerFunc {
8852 return func(w http.ResponseWriter, r *http.Request) {
89- logger := router.GetLogger(r)
53+ logger := web.cfg.Logger
9054
9155 clientInfo := shared.NewPicoPipeClient()
92- topic, _ := url.PathUnescape(router.GetField(r, 0))
56+ topic, _ := url.PathUnescape(r.PathValue("topic"))
9357
9458 topic = cleanRegex.ReplaceAllString(topic, "")
9559
......@@ -137,12 +101,12 @@ func handleSub(pubsub bool) http.HandlerFunc {
137101 }
138102 }
139103
140-func handlePub(pubsub bool) http.HandlerFunc {
104+func (web *webRouter) handlePub(pubsub bool) http.HandlerFunc {
141105 return func(w http.ResponseWriter, r *http.Request) {
142- logger := router.GetLogger(r)
106+ logger := web.cfg.Logger
143107
144108 clientInfo := shared.NewPicoPipeClient()
145- topic, _ := url.PathUnescape(router.GetField(r, 0))
109+ topic, _ := url.PathUnescape(r.PathValue("topic"))
146110
147111 topic = cleanRegex.ReplaceAllString(topic, "")
148112
......@@ -281,9 +245,9 @@ func handlePub(pubsub bool) http.HandlerFunc {
281245 }
282246 }
283247
284-func handlePipe() http.HandlerFunc {
248+func (web *webRouter) handlePipe() http.HandlerFunc {
285249 return func(w http.ResponseWriter, r *http.Request) {
286- logger := router.GetLogger(r)
250+ logger := web.cfg.Logger
287251
288252 c, err := upgrader.Upgrade(w, r, nil)
289253 if err != nil {
......@@ -296,7 +260,7 @@ func handlePipe() http.HandlerFunc {
296260 }()
297261
298262 clientInfo := shared.NewPicoPipeClient()
299- topic, _ := url.PathUnescape(router.GetField(r, 0))
263+ topic, _ := url.PathUnescape(r.PathValue("topic"))
300264
301265 topic = cleanRegex.ReplaceAllString(topic, "")
302266
......@@ -435,7 +399,7 @@ func handlePipe() http.HandlerFunc {
435399
436400 func rssHandler(cfg *shared.ConfigSite, dbpool DB) http.HandlerFunc {
437401 return func(w http.ResponseWriter, r *http.Request) {
438- apiToken, _ := url.PathUnescape(router.GetField(r, 0))
402+ apiToken, _ := url.PathUnescape(r.PathValue("token"))
439403 user, err := dbpool.FindUserByToken(apiToken)
440404 if err != nil {
441405 cfg.Logger.Error(
......@@ -470,33 +434,48 @@ func rssHandler(cfg *shared.ConfigSite, dbpool DB) http.HandlerFunc {
470434 }
471435 }
472436
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+}
488441
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)
492451 }
452+}
493453
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+}
498473
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
500479 }
501480
502481 func StartApiServer() {
......@@ -507,15 +486,6 @@ func StartApiServer() {
507486 }()
508487 logger := cfg.Logger
509488
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-
519489 info := shared.NewPicoPipeClient()
520490
521491 client, err := pipe.NewClient(context.Background(), logger.With("info", info), info)
......@@ -541,12 +511,8 @@ func StartApiServer() {
541511 }
542512 }()
543513
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())
550516
551517 portStr := fmt.Sprintf(":%s", cfg.Port)
552518 logger.Info(
......@@ -555,5 +521,5 @@ func StartApiServer() {
555521 "domain", cfg.Domain,
556522 )
557523
558- logger.Error("listen", "err", http.ListenAndServe(portStr, router).Error())
524+ logger.Error("listen", "err", http.ListenAndServe(portStr, handler).Error())
559525 }
+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 (
1010
1111 // DB is the storage pipe uses.
1212 type DB interface {
13+ FindUserByName(name string) (*db.User, error)
1314 FindUserByToken(token string) (*db.User, error)
1415 HasFeatureByUser(userID string, feature string) bool
1516 UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error
+129, -159
......@@ -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) {
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
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) {
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
156156
157157 user, err := dbpool.FindUserByName(username)
158158 if err != nil {
......@@ -277,21 +277,14 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
277277 }
278278 }
279279
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"))
291284 slug = strings.TrimSuffix(slug, "/")
292285
293- dbpool := getDB(r)
294- logger := router.GetLogger(r)
286+ dbpool := web.db
287+ logger := web.cfg.Logger
295288 logger = logger.With("slug", slug)
296289
297290 user, err := dbpool.FindUserByName(username)
......@@ -318,11 +311,11 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
318311 }
319312 }
320313
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
326319 user, err := dbpool.FindUserByName(username)
327320 if err != nil {
328321 logger.Info("blog not found", "user", username)
......@@ -344,21 +337,14 @@ func robotsHandler(w http.ResponseWriter, r *http.Request) {
344337 }
345338 }
346339
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"))
358344 slug = strings.TrimSuffix(slug, "/")
359345
360- dbpool := getDB(r)
361- logger := router.GetLogger(r)
346+ dbpool := web.db
347+ logger := web.cfg.Logger
362348
363349 user, err := dbpool.FindUserByName(username)
364350 if err != nil {
......@@ -547,10 +533,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
547533 }
548534 }
549535
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
554540
555541 posts, err := dbpool.FindPopularPosts()
556542 if err != nil {
......@@ -595,11 +581,11 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
595581 }
596582 }
597583
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
603589
604590 user, err := dbpool.FindUserByName(username)
605591 if err != nil {
......@@ -756,10 +742,10 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
756742 }
757743 }
758744
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
763749
764750 posts, err := dbpool.FindPopularPosts()
765751 if err != nil {
......@@ -857,63 +843,11 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
857843 }
858844 }
859845
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)
917851 user, err := dbpool.FindUserByName(username)
918852 if err != nil {
919853 logger.Error("could not find user", "username", username)
......@@ -922,8 +856,8 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
922856 }
923857 logger = shared.LoggerWithUser(logger, user)
924858
925- rawname := router.GetField(r, 0)
926- imgOpts := router.GetField(r, 1)
859+ rawname := r.PathValue("file")
860+ imgOpts := r.PathValue("opts")
927861 // we place all prose images inside a "prose" folder
928862 fname := filepath.Join("/prose", rawname)
929863
......@@ -946,36 +880,86 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
946880 imgproxy.ServeHTTP(w, r)
947881 }
948882
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+}
966888
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+}
977930
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)
979963 }
980964
981965 func StartApiServer() {
......@@ -993,22 +977,8 @@ func StartApiServer() {
993977 return
994978 }
995979
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())
1012982
1013983 portStr := fmt.Sprintf(":%s", cfg.Port)
1014984 logger.Info(
......@@ -1017,5 +987,5 @@ func StartApiServer() {
1017987 "domain", cfg.Domain,
1018988 )
1019989
1020- logger.Error(http.ListenAndServe(portStr, router).Error())
990+ logger.Error(http.ListenAndServe(portStr, handler).Error())
1021991 }
+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
33 import (
44 "fmt"
55 "math"
6- "net/http"
76 "sync"
87 "time"
98
......@@ -11,7 +10,6 @@ import (
1110 "github.com/jmoiron/sqlx"
1211 "github.com/picosh/pico/pkg/db"
1312 "github.com/picosh/pico/pkg/db/postgres"
14- "github.com/picosh/pico/pkg/shared/router"
1513 )
1614
1715 // DB is the storage prose uses.
......@@ -335,8 +333,3 @@ func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
335333 }
336334 return tags, nil
337335 }
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-}
+3, -1
......@@ -131,7 +131,9 @@ 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+ HasFeatureByUser(userID string, feature string) bool
136+}, secret string) error {
135137 if visit.PostID != "" {
136138 if !dbpool.HasFeatureByUser(visit.UserID, "plus") && !dbpool.HasFeatureByUser(visit.UserID, "prose") {
137139 return ErrAnalyticsDisabled
+28, -27
......@@ -7,6 +7,7 @@ import (
77 "os"
88 "strings"
99
10+ "github.com/picosh/pico/pkg/db"
1011 "github.com/picosh/pico/pkg/shared"
1112 )
1213
......@@ -40,43 +41,44 @@ func UnauthorizedHandler(w http.ResponseWriter, r *http.Request) {
4041 http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
4142 }
4243
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+ }
5861 }
5962 }
6063 }
61- }
6264
63- w.WriteHeader(http.StatusNotFound)
65+ w.WriteHeader(http.StatusNotFound)
66+ }
6467 }
6568
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 {
6772 subdomain := GetSubdomain(r)
68- cfg := GetCfg(r)
69-
7073 if !cfg.IsSubdomains() || subdomain == "" {
71- return GetField(r, 0)
74+ return r.PathValue("user")
7275 }
7376 return subdomain
7477 }
7578
76-func ServeFile(file string, contentType string) http.HandlerFunc {
79+func ServeFile(cfg *shared.ConfigSite, file string, contentType string) http.HandlerFunc {
7780 return func(w http.ResponseWriter, r *http.Request) {
78- logger := GetLogger(r)
79- cfg := GetCfg(r)
81+ logger := cfg.Logger
8082
8183 contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
8284 if err != nil {
......@@ -128,10 +130,9 @@ func RenderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Templ
128130 return ts, nil
129131 }
130132
131-func CreatePageHandler(fname string) http.HandlerFunc {
133+func CreatePageHandler(cfg *shared.ConfigSite, fname string) http.HandlerFunc {
132134 return func(w http.ResponseWriter, r *http.Request) {
133- logger := GetLogger(r)
134- cfg := GetCfg(r)
135+ logger := cfg.Logger
135136 ts, err := RenderTemplate(cfg, []string{cfg.StaticPath(fname)})
136137
137138 if err != nil {
+37, -150
......@@ -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,31 @@ 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- }
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+ })
16176 }
16277
163-type ctxDBKey struct{}
164-type ctxStorageKey struct{}
165-type ctxLoggerKey struct{}
166-type ctxCfg struct{}
167-
16878 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-}
19479
80+// GetSubdomain is the site a request is for, or "" for the main domain.
19581 func GetSubdomain(r *http.Request) string {
196- return r.Context().Value(CtxSubdomainKey{}).(string)
82+ subdomain, _ := r.Context().Value(CtxSubdomainKey{}).(string)
83+ return subdomain
19784 }
19885
19986 var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout)
+41, -0
......@@ -3,7 +3,11 @@ package router
33 import (
44 "errors"
55 "net"
6+ "net/http"
7+ "net/http/httptest"
68 "testing"
9+
10+ "github.com/picosh/pico/pkg/shared"
711 )
812
913 func TestGetCustomDomainCachesMisses(t *testing.T) {
......@@ -50,3 +54,40 @@ func TestGetCustomDomainCachesMisses(t *testing.T) {
5054 t.Fatalf("lookup after a transient failure got %q", got)
5155 }
5256 }
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+}