- commit
- 413f3a2
- parent
- a74b874
- author
- Eric Bower
- date
- 2024-10-27 15:48:32 +0000 UTC
refactor(pgs): use `http.ServeMux` v1.22 This change futher separates `pgs` from most of the other services. One important change is refactoring our web router to use the new `http.ServeMux` router that does a better job of routing with templated strings. Further, we try to leverage the `http.Handler` interface that removes the need to use `context.Context` as much.
8 files changed,
+520,
-422
+12,
-3
1@@ -173,14 +173,16 @@ func anyPerm(proj *db.Project) bool {
2 func ImgRequest(w http.ResponseWriter, r *http.Request) {
3 subdomain := shared.GetSubdomain(r)
4 cfg := shared.GetCfg(r)
5+ st := shared.GetStorage(r)
6 dbpool := shared.GetDB(r)
7 logger := shared.GetLogger(r)
8 username := shared.GetUsernameFromRequest(r)
9+ analytics := shared.GetAnalyticsQueue(r)
10
11 user, err := dbpool.FindUserForName(username)
12 if err != nil {
13- logger.Info("rss feed not found", "user", username)
14- http.Error(w, "rss feed not found", http.StatusNotFound)
15+ logger.Info("user not found", "user", username)
16+ http.Error(w, "user not found", http.StatusNotFound)
17 return
18 }
19
20@@ -234,7 +236,14 @@ func ImgRequest(w http.ResponseWriter, r *http.Request) {
21 }
22
23 fname := post.Filename
24- pgs.ServeAsset(fname, opts, true, anyPerm, w, r)
25+ router := pgs.NewWebRouter(
26+ cfg,
27+ logger,
28+ dbpool,
29+ st,
30+ analytics,
31+ )
32+ router.ServeAsset(fname, opts, true, anyPerm, w, r)
33 }
34
35 func FindImgPost(r *http.Request, user *db.User, slug string) (*db.Post, error) {
+130,
-332
1@@ -1,19 +1,14 @@
2 package pgs
3
4 import (
5- "errors"
6+ "context"
7 "fmt"
8- "html/template"
9- "io"
10 "log/slog"
11 "net/http"
12- "net/url"
13- "path/filepath"
14 "regexp"
15 "strings"
16 "time"
17
18- "net/http/httputil"
19 _ "net/http/pprof"
20
21 "github.com/gorilla/feeds"
22@@ -24,26 +19,73 @@ import (
23 sst "github.com/picosh/pobj/storage"
24 )
25
26-type AssetHandler struct {
27- Username string
28- Subdomain string
29- Filepath string
30- ProjectDir string
31+type SubdomainProps struct {
32+ ProjectName string
33+ Username string
34+}
35+
36+func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
37+ props := &SubdomainProps{}
38+ strs := strings.SplitN(subdomain, "-", 2)
39+ props.Username = strs[0]
40+ if len(strs) == 2 {
41+ props.ProjectName = strs[1]
42+ } else {
43+ props.ProjectName = props.Username
44+ }
45+ return props, nil
46+}
47+
48+type HasPerm = func(proj *db.Project) bool
49+
50+type WebRouter struct {
51 Cfg *shared.ConfigSite
52+ Logger *slog.Logger
53 Dbpool db.DB
54 Storage storage.StorageServe
55- Logger *slog.Logger
56- UserID string
57- Bucket sst.Bucket
58- ImgProcessOpts *storage.ImgProcessOpts
59- ProjectID string
60- HasPicoPlus bool
61+ AnalyticsQueue chan *db.AnalyticsVisits
62+ RootRouter *http.ServeMux
63+ UserRouter *http.ServeMux
64+}
65+
66+func NewWebRouter(cfg *shared.ConfigSite, logger *slog.Logger, dbpool db.DB, st storage.StorageServe, analytics chan *db.AnalyticsVisits) *WebRouter {
67+ router := &WebRouter{
68+ Cfg: cfg,
69+ Logger: logger,
70+ Dbpool: dbpool,
71+ Storage: st,
72+ AnalyticsQueue: analytics,
73+ }
74+ router.initRouters()
75+ return router
76+}
77+
78+func (web *WebRouter) initRouters() {
79+ // root domain
80+ rootRouter := http.NewServeMux()
81+ rootRouter.HandleFunc("GET /check", web.checkHandler)
82+ rootRouter.Handle("GET /main.css", shared.ServeFile("main.css", "text/css"))
83+ rootRouter.Handle("GET /card.png", shared.ServeFile("card.png", "image/png"))
84+ rootRouter.Handle("GET /favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png"))
85+ rootRouter.Handle("GET /apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png"))
86+ rootRouter.Handle("GET /favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon"))
87+ rootRouter.Handle("GET /robots.txt", shared.ServeFile("robots.txt", "text/plain"))
88+ rootRouter.Handle("GET /rss/updated", web.createRssHandler("updated_at"))
89+ rootRouter.Handle("GET /rss", web.createRssHandler("created_at"))
90+ rootRouter.Handle("GET /{$}", shared.CreatePageHandler("html/marketing.page.tmpl"))
91+ web.RootRouter = rootRouter
92+
93+ // subdomain or custom domains
94+ userRouter := http.NewServeMux()
95+ userRouter.HandleFunc("GET /{fname...}", web.AssetRequest)
96+ userRouter.HandleFunc("GET /{$}", web.AssetRequest)
97+ web.UserRouter = userRouter
98 }
99
100-func checkHandler(w http.ResponseWriter, r *http.Request) {
101- dbpool := shared.GetDB(r)
102- cfg := shared.GetCfg(r)
103- logger := shared.GetLogger(r)
104+func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
105+ dbpool := web.Dbpool
106+ cfg := web.Cfg
107+ logger := web.Logger
108
109 if cfg.IsCustomdomains() {
110 hostDomain := r.URL.Query().Get("domain")
111@@ -95,15 +137,11 @@ func checkHandler(w http.ResponseWriter, r *http.Request) {
112 w.WriteHeader(http.StatusNotFound)
113 }
114
115-type RssData struct {
116- Contents template.HTML
117-}
118-
119-func createRssHandler(by string) http.HandlerFunc {
120+func (web *WebRouter) createRssHandler(by string) http.HandlerFunc {
121 return func(w http.ResponseWriter, r *http.Request) {
122- dbpool := shared.GetDB(r)
123- logger := shared.GetLogger(r)
124- cfg := shared.GetCfg(r)
125+ dbpool := web.Dbpool
126+ logger := web.Logger
127+ cfg := web.Cfg
128
129 pager, err := dbpool.FindAllProjects(&db.Pager{Num: 100, Page: 0}, by)
130 if err != nil {
131@@ -163,256 +201,48 @@ func createRssHandler(by string) http.HandlerFunc {
132 }
133 }
134
135-func hasProtocol(url string) bool {
136- isFullUrl := strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
137- return isFullUrl
138+func (web *WebRouter) Perm(proj *db.Project) bool {
139+ return proj.Acl.Type == "public"
140 }
141
142-func (h *AssetHandler) handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
143- var redirects []*RedirectRule
144- redirectFp, redirectInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
145- if err == nil {
146- defer redirectFp.Close()
147- if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
148- errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
149- logger.Error(errMsg)
150- http.Error(w, errMsg, http.StatusInternalServerError)
151- return
152- }
153- buf := new(strings.Builder)
154- lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
155- _, err := io.Copy(buf, lr)
156- if err != nil {
157- logger.Error("io copy", "err", err.Error())
158- http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
159- return
160- }
161-
162- redirects, err = parseRedirectText(buf.String())
163- if err != nil {
164- logger.Error("could not parse redirect text", "err", err.Error())
165- }
166- }
167-
168- routes := calcRoutes(h.ProjectDir, h.Filepath, redirects)
169-
170- var contents io.ReadCloser
171- contentType := ""
172- assetFilepath := ""
173- info := &sst.ObjectInfo{}
174- status := http.StatusOK
175- attempts := []string{}
176- for _, fp := range routes {
177- if checkIsRedirect(fp.Status) {
178- // hack: check to see if there's an index file in the requested directory
179- // before redirecting, this saves a hop that will just end up a 404
180- if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
181- next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
182- _, _, err := h.Storage.GetObject(h.Bucket, next)
183- if err != nil {
184- continue
185- }
186- }
187- logger.Info(
188- "redirecting request",
189- "destination", fp.Filepath,
190- "status", fp.Status,
191- )
192- http.Redirect(w, r, fp.Filepath, fp.Status)
193- return
194- } else if hasProtocol(fp.Filepath) {
195- if !h.HasPicoPlus {
196- msg := "must be pico+ user to fetch content from external source"
197- logger.Error(
198- msg,
199- "destination", fp.Filepath,
200- "status", fp.Status,
201- )
202- http.Error(w, msg, http.StatusUnauthorized)
203- return
204- }
205-
206- logger.Info(
207- "fetching content from external service",
208- "destination", fp.Filepath,
209- "status", fp.Status,
210- )
211-
212- destUrl, err := url.Parse(fp.Filepath)
213- if err != nil {
214- http.Error(w, err.Error(), http.StatusInternalServerError)
215- return
216- }
217- proxy := httputil.NewSingleHostReverseProxy(destUrl)
218- oldDirector := proxy.Director
219- proxy.Director = func(r *http.Request) {
220- oldDirector(r)
221- r.Host = destUrl.Host
222- r.URL = destUrl
223- }
224- proxy.ServeHTTP(w, r)
225- return
226- }
227-
228- attempts = append(attempts, fp.Filepath)
229- mimeType := storage.GetMimeType(fp.Filepath)
230- logger = logger.With("filename", fp.Filepath)
231- var c io.ReadCloser
232- var err error
233- if strings.HasPrefix(mimeType, "image/") {
234- c, contentType, err = h.Storage.ServeObject(
235- h.Bucket,
236- fp.Filepath,
237- h.ImgProcessOpts,
238- )
239- } else {
240- c, info, err = h.Storage.GetObject(h.Bucket, fp.Filepath)
241- }
242- if err == nil {
243- contents = c
244- assetFilepath = fp.Filepath
245- status = fp.Status
246- break
247- }
248- }
249+var imgRegex = regexp.MustCompile("(.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)")
250
251- if assetFilepath == "" {
252- logger.Info(
253- "asset not found in bucket",
254- "routes", strings.Join(attempts, ", "),
255- "status", http.StatusNotFound,
256- )
257- // track 404s
258- ch := shared.GetAnalyticsQueue(r)
259- view, err := shared.AnalyticsVisitFromRequest(r, h.UserID, h.Cfg.Secret)
260- if err == nil {
261- view.ProjectID = h.ProjectID
262- view.Status = http.StatusNotFound
263- ch <- view
264- } else {
265- if !errors.Is(err, shared.ErrAnalyticsDisabled) {
266- logger.Error("could not record analytics view", "err", err)
267- }
268- }
269- http.Error(w, "404 not found", http.StatusNotFound)
270+func (web *WebRouter) AssetRequest(w http.ResponseWriter, r *http.Request) {
271+ fname := r.PathValue("fname")
272+ if imgRegex.MatchString(fname) {
273+ web.ImageRequest(w, r)
274 return
275 }
276- defer contents.Close()
277-
278- if contentType == "" {
279- contentType = storage.GetMimeType(assetFilepath)
280- }
281-
282- var headers []*HeaderRule
283- headersFp, headersInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
284- if err == nil {
285- defer headersFp.Close()
286- if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
287- errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
288- logger.Error(errMsg)
289- http.Error(w, errMsg, http.StatusInternalServerError)
290- return
291- }
292- buf := new(strings.Builder)
293- lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
294- _, err := io.Copy(buf, lr)
295- if err != nil {
296- logger.Error("io copy", "err", err.Error())
297- http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
298- return
299- }
300-
301- headers, err = parseHeaderText(buf.String())
302- if err != nil {
303- logger.Error("could not parse header text", "err", err.Error())
304- }
305- }
306-
307- userHeaders := []*HeaderLine{}
308- for _, headerRule := range headers {
309- rr := regexp.MustCompile(headerRule.Path)
310- match := rr.FindStringSubmatch(assetFilepath)
311- if len(match) > 0 {
312- userHeaders = headerRule.Headers
313- }
314- }
315-
316- if info != nil {
317- if info.ETag != "" {
318- w.Header().Add("etag", info.ETag)
319- }
320-
321- if !info.LastModified.IsZero() {
322- w.Header().Add("last-modified", info.LastModified.Format(http.TimeFormat))
323- }
324- }
325+ web.ServeAsset(fname, nil, false, web.Perm, w, r)
326+}
327
328- for _, hdr := range userHeaders {
329- w.Header().Add(hdr.Name, hdr.Value)
330- }
331- if w.Header().Get("content-type") == "" {
332- w.Header().Set("content-type", contentType)
333+func (web *WebRouter) ImageRequest(w http.ResponseWriter, r *http.Request) {
334+ rawname := r.PathValue("fname")
335+ matches := imgRegex.FindStringSubmatch(rawname)
336+ fname := rawname
337+ imgOpts := ""
338+ if len(matches) >= 2 {
339+ fname = matches[1]
340 }
341-
342- finContentType := w.Header().Get("content-type")
343-
344- // only track pages, not individual assets
345- if finContentType == "text/html" {
346- // track visit
347- ch := shared.GetAnalyticsQueue(r)
348- view, err := shared.AnalyticsVisitFromRequest(r, h.UserID, h.Cfg.Secret)
349- if err == nil {
350- view.ProjectID = h.ProjectID
351- ch <- view
352- } else {
353- if !errors.Is(err, shared.ErrAnalyticsDisabled) {
354- logger.Error("could not record analytics view", "err", err)
355- }
356- }
357+ if len(matches) >= 3 {
358+ imgOpts = matches[2]
359 }
360
361- logger.Info(
362- "serving asset",
363- "asset", assetFilepath,
364- "status", status,
365- "contentType", finContentType,
366- )
367-
368- w.WriteHeader(status)
369- _, err = io.Copy(w, contents)
370-
371+ opts, err := storage.UriToImgProcessOpts(imgOpts)
372 if err != nil {
373- logger.Error("io copy", "err", err.Error())
374- }
375-}
376-
377-type SubdomainProps struct {
378- ProjectName string
379- Username string
380-}
381-
382-func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
383- props := &SubdomainProps{}
384- strs := strings.SplitN(subdomain, "-", 2)
385- props.Username = strs[0]
386-
387- if len(strs) == 2 {
388- props.ProjectName = strs[1]
389- } else {
390- props.ProjectName = props.Username
391+ errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
392+ web.Logger.Error("error processing img options", "err", errMsg)
393+ http.Error(w, errMsg, http.StatusUnprocessableEntity)
394+ return
395 }
396
397- return props, nil
398+ web.ServeAsset(fname, opts, false, web.Perm, w, r)
399 }
400
401-func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
402+func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
403 subdomain := shared.GetSubdomain(r)
404- cfg := shared.GetCfg(r)
405- dbpool := shared.GetDB(r)
406- st := shared.GetStorage(r)
407- ologger := shared.GetLogger(r)
408
409- logger := ologger.With(
410+ logger := web.Logger.With(
411 "subdomain", subdomain,
412 "filename", fname,
413 "url", fmt.Sprintf("%s%s", r.Host, r.URL.Path),
414@@ -422,7 +252,7 @@ func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPe
415 props, err := getProjectFromSubdomain(subdomain)
416 if err != nil {
417 logger.Info(
418- "could parse project from subdomain",
419+ "could not determine project from subdomain",
420 "err", err,
421 )
422 http.Error(w, err.Error(), http.StatusNotFound)
423@@ -434,7 +264,7 @@ func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPe
424 "user", props.Username,
425 )
426
427- user, err := dbpool.FindUserForName(props.Username)
428+ user, err := web.Dbpool.FindUserForName(props.Username)
429 if err != nil {
430 logger.Info("user not found")
431 http.Error(w, "user not found", http.StatusNotFound)
432@@ -452,10 +282,10 @@ func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPe
433 var bucket sst.Bucket
434 // imgs has a different bucket directory
435 if fromImgs {
436- bucket, err = st.GetBucket(shared.GetImgsBucketName(user.ID))
437+ bucket, err = web.Storage.GetBucket(shared.GetImgsBucketName(user.ID))
438 } else {
439- bucket, err = st.GetBucket(shared.GetAssetBucketName(user.ID))
440- project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
441+ bucket, err = web.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
442+ project, err := web.Dbpool.FindProjectByName(user.ID, props.ProjectName)
443 if err != nil {
444 logger.Info("project not found")
445 http.Error(w, "project not found", http.StatusNotFound)
446@@ -487,80 +317,54 @@ func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPe
447 return
448 }
449
450- hasPicoPlus := dbpool.HasFeatureForUser(user.ID, "plus")
451+ hasPicoPlus := web.Dbpool.HasFeatureForUser(user.ID, "plus")
452+
453+ asset := &ApiAssetHandler{
454+ WebRouter: web,
455+ Logger: logger,
456
457- asset := &AssetHandler{
458 Username: props.Username,
459 UserID: user.ID,
460 Subdomain: subdomain,
461 ProjectDir: projectDir,
462 Filepath: fname,
463- Cfg: cfg,
464- Dbpool: dbpool,
465- Storage: st,
466- Logger: logger,
467 Bucket: bucket,
468 ImgProcessOpts: opts,
469 ProjectID: projectID,
470 HasPicoPlus: hasPicoPlus,
471 }
472
473- asset.handle(logger, w, r)
474+ asset.ServeHTTP(w, r)
475 }
476
477-type HasPerm = func(proj *db.Project) bool
478-
479-func ImgAssetRequest(hasPerm HasPerm) http.HandlerFunc {
480- return func(w http.ResponseWriter, r *http.Request) {
481- logger := shared.GetLogger(r)
482- fname, _ := url.PathUnescape(shared.GetField(r, 0))
483- imgOpts, _ := url.PathUnescape(shared.GetField(r, 1))
484- opts, err := storage.UriToImgProcessOpts(imgOpts)
485- if err != nil {
486- errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
487- logger.Error("error processing img options", "err", errMsg)
488- http.Error(w, errMsg, http.StatusUnprocessableEntity)
489- }
490-
491- ServeAsset(fname, opts, false, hasPerm, w, r)
492+func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
493+ subdomain := shared.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.Space)
494+ if web.RootRouter == nil || web.UserRouter == nil {
495+ web.Logger.Error("routers not initialized")
496+ http.Error(w, "routers not initialized", http.StatusInternalServerError)
497+ return
498 }
499-}
500
501-func AssetRequest(hasPerm HasPerm) http.HandlerFunc {
502- return func(w http.ResponseWriter, r *http.Request) {
503- fname, _ := url.PathUnescape(shared.GetField(r, 0))
504- ServeAsset(fname, nil, false, hasPerm, w, r)
505+ var router *http.ServeMux
506+ if subdomain == "" {
507+ router = web.RootRouter
508+ } else {
509+ router = web.UserRouter
510 }
511-}
512-
513-var mainRoutes = []shared.Route{
514- shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
515- shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
516- shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
517- shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
518- shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
519- shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
520-
521- shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
522- shared.NewRoute("GET", "/check", checkHandler),
523- shared.NewRoute("GET", "/rss/updated", createRssHandler("updated_at")),
524- shared.NewRoute("GET", "/rss", createRssHandler("created_at")),
525- shared.NewRoute("GET", "/(.+)", shared.CreatePageHandler("html/marketing.page.tmpl")),
526-}
527-
528-func createSubdomainRoutes(hasPerm HasPerm) []shared.Route {
529- assetRequest := AssetRequest(hasPerm)
530- imgRequest := ImgAssetRequest(hasPerm)
531
532- return []shared.Route{
533- shared.NewRoute("GET", "/", assetRequest),
534- shared.NewRoute("GET", "(/.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)", imgRequest),
535- shared.NewRoute("GET", "(/.+)", assetRequest),
536+ // enable cors
537+ // TODO: I don't think we want this for pgs as a default
538+ // users can enable cors headers using `_headers` file
539+ /* if r.Method == "OPTIONS" {
540+ shared.CorsHeaders(w.Header())
541+ w.WriteHeader(http.StatusOK)
542+ return
543 }
544-}
545+ shared.CorsHeaders(w.Header()) */
546
547-func publicPerm(proj *db.Project) bool {
548- return proj.Acl.Type == "public"
549+ ctx := r.Context()
550+ ctx = context.WithValue(ctx, shared.CtxSubdomainKey{}, subdomain)
551+ router.ServeHTTP(w, r.WithContext(ctx))
552 }
553
554 func StartApiServer() {
555@@ -579,20 +383,14 @@ func StartApiServer() {
556 }
557
558 if err != nil {
559- logger.Error("could not connect to minio", "err", err.Error())
560+ logger.Error("could not connect to object storage", "err", err.Error())
561 return
562 }
563
564 ch := make(chan *db.AnalyticsVisits)
565 go shared.AnalyticsCollect(ch, dbpool, logger)
566- apiConfig := &shared.ApiConfig{
567- Cfg: cfg,
568- Dbpool: dbpool,
569- Storage: st,
570- AnalyticsQueue: ch,
571- }
572- handler := shared.CreateServe(mainRoutes, createSubdomainRoutes(publicPerm), apiConfig)
573- router := http.HandlerFunc(handler)
574+
575+ routes := NewWebRouter(cfg, logger, dbpool, st, ch)
576
577 portStr := fmt.Sprintf(":%s", cfg.Port)
578 logger.Info(
579@@ -600,7 +398,7 @@ func StartApiServer() {
580 "port", cfg.Port,
581 "domain", cfg.Domain,
582 )
583- err = http.ListenAndServe(portStr, router)
584+ err = http.ListenAndServe(portStr, routes)
585 logger.Error(
586 "listen and serve",
587 "err", err.Error(),
+259,
-0
1@@ -0,0 +1,259 @@
2+package pgs
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "net/http"
10+ "net/url"
11+ "path/filepath"
12+ "regexp"
13+ "strings"
14+
15+ "net/http/httputil"
16+ _ "net/http/pprof"
17+
18+ "github.com/picosh/pico/shared"
19+ "github.com/picosh/pico/shared/storage"
20+ sst "github.com/picosh/pobj/storage"
21+)
22+
23+type ApiAssetHandler struct {
24+ *WebRouter
25+ Logger *slog.Logger
26+
27+ Username string
28+ UserID string
29+ Subdomain string
30+ ProjectDir string
31+ Filepath string
32+ Bucket sst.Bucket
33+ ImgProcessOpts *storage.ImgProcessOpts
34+ ProjectID string
35+ HasPicoPlus bool
36+}
37+
38+func hasProtocol(url string) bool {
39+ isFullUrl := strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
40+ return isFullUrl
41+}
42+
43+func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44+ logger := h.Logger
45+ var redirects []*RedirectRule
46+ redirectFp, redirectInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
47+ if err == nil {
48+ defer redirectFp.Close()
49+ if redirectInfo != nil && redirectInfo.Size > h.Cfg.MaxSpecialFileSize {
50+ errMsg := fmt.Sprintf("_redirects file is too large (%d > %d)", redirectInfo.Size, h.Cfg.MaxSpecialFileSize)
51+ logger.Error(errMsg)
52+ http.Error(w, errMsg, http.StatusInternalServerError)
53+ return
54+ }
55+ buf := new(strings.Builder)
56+ lr := io.LimitReader(redirectFp, h.Cfg.MaxSpecialFileSize)
57+ _, err := io.Copy(buf, lr)
58+ if err != nil {
59+ logger.Error("io copy", "err", err.Error())
60+ http.Error(w, "cannot read _redirects file", http.StatusInternalServerError)
61+ return
62+ }
63+
64+ redirects, err = parseRedirectText(buf.String())
65+ if err != nil {
66+ logger.Error("could not parse redirect text", "err", err.Error())
67+ }
68+ }
69+
70+ routes := calcRoutes(h.ProjectDir, h.Filepath, redirects)
71+
72+ var contents io.ReadCloser
73+ contentType := ""
74+ assetFilepath := ""
75+ info := &sst.ObjectInfo{}
76+ status := http.StatusOK
77+ attempts := []string{}
78+ for _, fp := range routes {
79+ if checkIsRedirect(fp.Status) {
80+ // hack: check to see if there's an index file in the requested directory
81+ // before redirecting, this saves a hop that will just end up a 404
82+ if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
83+ next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
84+ _, _, err := h.Storage.GetObject(h.Bucket, next)
85+ if err != nil {
86+ continue
87+ }
88+ }
89+ logger.Info(
90+ "redirecting request",
91+ "destination", fp.Filepath,
92+ "status", fp.Status,
93+ )
94+ http.Redirect(w, r, fp.Filepath, fp.Status)
95+ return
96+ } else if hasProtocol(fp.Filepath) {
97+ if !h.HasPicoPlus {
98+ msg := "must be pico+ user to fetch content from external source"
99+ logger.Error(
100+ msg,
101+ "destination", fp.Filepath,
102+ "status", fp.Status,
103+ )
104+ http.Error(w, msg, http.StatusUnauthorized)
105+ return
106+ }
107+
108+ logger.Info(
109+ "fetching content from external service",
110+ "destination", fp.Filepath,
111+ "status", fp.Status,
112+ )
113+
114+ destUrl, err := url.Parse(fp.Filepath)
115+ if err != nil {
116+ http.Error(w, err.Error(), http.StatusInternalServerError)
117+ return
118+ }
119+ proxy := httputil.NewSingleHostReverseProxy(destUrl)
120+ oldDirector := proxy.Director
121+ proxy.Director = func(r *http.Request) {
122+ oldDirector(r)
123+ r.Host = destUrl.Host
124+ r.URL = destUrl
125+ }
126+ proxy.ServeHTTP(w, r)
127+ return
128+ }
129+
130+ attempts = append(attempts, fp.Filepath)
131+ mimeType := storage.GetMimeType(fp.Filepath)
132+ logger = logger.With("filename", fp.Filepath)
133+ var c io.ReadCloser
134+ var err error
135+ if strings.HasPrefix(mimeType, "image/") {
136+ c, contentType, err = h.Storage.ServeObject(
137+ h.Bucket,
138+ fp.Filepath,
139+ h.ImgProcessOpts,
140+ )
141+ } else {
142+ c, info, err = h.Storage.GetObject(h.Bucket, fp.Filepath)
143+ }
144+ if err == nil {
145+ contents = c
146+ assetFilepath = fp.Filepath
147+ status = fp.Status
148+ break
149+ }
150+ }
151+
152+ if assetFilepath == "" {
153+ logger.Info(
154+ "asset not found in bucket",
155+ "routes", strings.Join(attempts, ", "),
156+ "status", http.StatusNotFound,
157+ )
158+ // track 404s
159+ ch := h.AnalyticsQueue
160+ view, err := shared.AnalyticsVisitFromRequest(r, h.Dbpool, h.UserID, h.Cfg.Secret)
161+ if err == nil {
162+ view.ProjectID = h.ProjectID
163+ view.Status = http.StatusNotFound
164+ ch <- view
165+ } else {
166+ if !errors.Is(err, shared.ErrAnalyticsDisabled) {
167+ logger.Error("could not record analytics view", "err", err)
168+ }
169+ }
170+ http.Error(w, "404 not found", http.StatusNotFound)
171+ return
172+ }
173+ defer contents.Close()
174+
175+ if contentType == "" {
176+ contentType = storage.GetMimeType(assetFilepath)
177+ }
178+
179+ var headers []*HeaderRule
180+ headersFp, headersInfo, err := h.Storage.GetObject(h.Bucket, filepath.Join(h.ProjectDir, "_headers"))
181+ if err == nil {
182+ defer headersFp.Close()
183+ if headersInfo != nil && headersInfo.Size > h.Cfg.MaxSpecialFileSize {
184+ errMsg := fmt.Sprintf("_headers file is too large (%d > %d)", headersInfo.Size, h.Cfg.MaxSpecialFileSize)
185+ logger.Error(errMsg)
186+ http.Error(w, errMsg, http.StatusInternalServerError)
187+ return
188+ }
189+ buf := new(strings.Builder)
190+ lr := io.LimitReader(headersFp, h.Cfg.MaxSpecialFileSize)
191+ _, err := io.Copy(buf, lr)
192+ if err != nil {
193+ logger.Error("io copy", "err", err.Error())
194+ http.Error(w, "cannot read _headers file", http.StatusInternalServerError)
195+ return
196+ }
197+
198+ headers, err = parseHeaderText(buf.String())
199+ if err != nil {
200+ logger.Error("could not parse header text", "err", err.Error())
201+ }
202+ }
203+
204+ userHeaders := []*HeaderLine{}
205+ for _, headerRule := range headers {
206+ rr := regexp.MustCompile(headerRule.Path)
207+ match := rr.FindStringSubmatch(assetFilepath)
208+ if len(match) > 0 {
209+ userHeaders = headerRule.Headers
210+ }
211+ }
212+
213+ if info != nil {
214+ if info.ETag != "" {
215+ w.Header().Add("etag", info.ETag)
216+ }
217+
218+ if !info.LastModified.IsZero() {
219+ w.Header().Add("last-modified", info.LastModified.Format(http.TimeFormat))
220+ }
221+ }
222+
223+ for _, hdr := range userHeaders {
224+ w.Header().Add(hdr.Name, hdr.Value)
225+ }
226+ if w.Header().Get("content-type") == "" {
227+ w.Header().Set("content-type", contentType)
228+ }
229+
230+ finContentType := w.Header().Get("content-type")
231+
232+ // only track pages, not individual assets
233+ if finContentType == "text/html" {
234+ // track visit
235+ ch := h.AnalyticsQueue
236+ view, err := shared.AnalyticsVisitFromRequest(r, h.Dbpool, h.UserID, h.Cfg.Secret)
237+ if err == nil {
238+ view.ProjectID = h.ProjectID
239+ ch <- view
240+ } else {
241+ if !errors.Is(err, shared.ErrAnalyticsDisabled) {
242+ logger.Error("could not record analytics view", "err", err)
243+ }
244+ }
245+ }
246+
247+ logger.Info(
248+ "serving asset",
249+ "asset", assetFilepath,
250+ "status", status,
251+ "contentType", finContentType,
252+ )
253+
254+ w.WriteHeader(status)
255+ _, err = io.Copy(w, contents)
256+
257+ if err != nil {
258+ logger.Error("io copy", "err", err.Error())
259+ }
260+}
+70,
-45
1@@ -220,15 +220,8 @@ func TestApiBasic(t *testing.T) {
2
3 st, _ := storage.NewStorageMemory(tc.storage)
4 ch := make(chan *db.AnalyticsVisits)
5- apiConfig := &shared.ApiConfig{
6- Cfg: cfg,
7- Dbpool: tc.dbpool,
8- Storage: st,
9- AnalyticsQueue: ch,
10- }
11- handler := shared.CreateServe(mainRoutes, createSubdomainRoutes(publicPerm), apiConfig)
12- router := http.HandlerFunc(handler)
13- router(responseRecorder, request)
14+ router := NewWebRouter(cfg, cfg.Logger, tc.dbpool, st, ch)
15+ router.ServeHTTP(responseRecorder, request)
16
17 if responseRecorder.Code != tc.status {
18 t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
19@@ -263,14 +256,7 @@ func TestAnalytics(t *testing.T) {
20 st, _ := storage.NewStorageMemory(sto)
21 ch := make(chan *db.AnalyticsVisits)
22 dbpool := NewPgsAnalticsDb(cfg.Logger)
23- apiConfig := &shared.ApiConfig{
24- Cfg: cfg,
25- Dbpool: dbpool,
26- Storage: st,
27- AnalyticsQueue: ch,
28- }
29- handler := shared.CreateServe(mainRoutes, createSubdomainRoutes(publicPerm), apiConfig)
30- router := http.HandlerFunc(handler)
31+ router := NewWebRouter(cfg, cfg.Logger, dbpool, st, ch)
32
33 go func() {
34 for analytics := range ch {
35@@ -281,7 +267,7 @@ func TestAnalytics(t *testing.T) {
36 }
37 }()
38
39- router(responseRecorder, request)
40+ router.ServeHTTP(responseRecorder, request)
41
42 select {
43 case <-ch:
44@@ -307,38 +293,77 @@ func TestImageManipulation(t *testing.T) {
45 bucketName := shared.GetAssetBucketName(testUserID)
46 cfg := NewConfigSite()
47 cfg.Domain = "pgs.test"
48- expectedPath := "/app.jpg/s:500/rt:90"
49- request := httptest.NewRequest("GET", mkpath(expectedPath), strings.NewReader(""))
50- responseRecorder := httptest.NewRecorder()
51
52- sto := map[string]map[string]string{
53- bucketName: {
54- "test/app.jpg": "hello world!",
55+ tt := []ApiExample{
56+ {
57+ name: "root-img",
58+ path: "/app.jpg/s:500/rt:90",
59+ want: "hello world!",
60+ status: http.StatusOK,
61+ contentType: "image/jpeg",
62+
63+ dbpool: NewPgsDb(cfg.Logger),
64+ storage: map[string]map[string]string{
65+ bucketName: {
66+ "test/app.jpg": "hello world!",
67+ },
68+ },
69 },
70- }
71- memst, _ := storage.NewStorageMemory(sto)
72- st := &ImageStorageMemory{StorageMemory: memst}
73- ch := make(chan *db.AnalyticsVisits)
74- dbpool := NewPgsAnalticsDb(cfg.Logger)
75- apiConfig := &shared.ApiConfig{
76- Cfg: cfg,
77- Dbpool: dbpool,
78- Storage: st,
79- AnalyticsQueue: ch,
80- }
81- handler := shared.CreateServe(mainRoutes, createSubdomainRoutes(publicPerm), apiConfig)
82- router := http.HandlerFunc(handler)
83- router(responseRecorder, request)
84+ {
85+ name: "root-subdir-img",
86+ path: "/subdir/app.jpg/rt:90/s:500",
87+ want: "hello world!",
88+ status: http.StatusOK,
89+ contentType: "image/jpeg",
90
91- if st.Fpath != "test/app.jpg" {
92- t.Errorf("Want path '%s', got '%s'", "test/app.jpg", st.Fpath)
93+ dbpool: NewPgsDb(cfg.Logger),
94+ storage: map[string]map[string]string{
95+ bucketName: {
96+ "test/subdir/app.jpg": "hello world!",
97+ },
98+ },
99+ },
100 }
101
102- if st.Opts.Ratio.Width != 500 {
103- t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
104- }
105+ for _, tc := range tt {
106+ t.Run(tc.name, func(t *testing.T) {
107+ request := httptest.NewRequest("GET", mkpath(tc.path), strings.NewReader(""))
108+ responseRecorder := httptest.NewRecorder()
109+
110+ memst, _ := storage.NewStorageMemory(tc.storage)
111+ st := &ImageStorageMemory{
112+ StorageMemory: memst,
113+ Opts: &storage.ImgProcessOpts{
114+ Ratio: &storage.Ratio{},
115+ },
116+ }
117+ ch := make(chan *db.AnalyticsVisits)
118+ router := NewWebRouter(cfg, cfg.Logger, tc.dbpool, st, ch)
119+ router.ServeHTTP(responseRecorder, request)
120+
121+ if responseRecorder.Code != tc.status {
122+ t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
123+ }
124
125- if st.Opts.Rotate != 90 {
126- t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
127+ ct := responseRecorder.Header().Get("content-type")
128+ if ct != tc.contentType {
129+ t.Errorf("Want status '%s', got '%s'", tc.contentType, ct)
130+ }
131+
132+ body := strings.TrimSpace(responseRecorder.Body.String())
133+ if body != tc.want {
134+ t.Errorf("Want '%s', got '%s'", tc.want, body)
135+ }
136+
137+ if st.Opts.Ratio.Width != 500 {
138+ t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
139+ return
140+ }
141+
142+ if st.Opts.Rotate != 90 {
143+ t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
144+ return
145+ }
146+ })
147 }
148 }
+23,
-14
1@@ -1,19 +1,20 @@
2 package pgs
3
4 import (
5- "context"
6- "encoding/json"
7 "net/http"
8 "strings"
9
10 "github.com/charmbracelet/ssh"
11 "github.com/picosh/pico/db"
12 "github.com/picosh/pico/shared"
13- "github.com/picosh/pico/ui"
14 "github.com/picosh/utils"
15 )
16
17-func allowPerm(proj *db.Project) bool {
18+type TunnelWebRouter struct {
19+ *WebRouter
20+}
21+
22+func (web *TunnelWebRouter) Perm(proj *db.Project) bool {
23 return true
24 }
25
26@@ -97,7 +98,7 @@ func createHttpHandler(apiConfig *shared.ApiConfig) CtxHttpBridge {
27
28 log.Info("user has access to site")
29
30- routes := []shared.Route{
31+ /* routes := []shared.Route{
32 // special API endpoint for tunnel users accessing site
33 shared.NewCorsRoute("GET", "/api/current_user", func(w http.ResponseWriter, r *http.Request) {
34 w.Header().Set("Content-Type", "application/json")
35@@ -113,19 +114,27 @@ func createHttpHandler(apiConfig *shared.ApiConfig) CtxHttpBridge {
36 log.Error(err.Error())
37 }
38 }),
39- }
40-
41- if subdomain == "pico-ui" || subdomain == "erock-ui" {
42- rts := ui.CreateRoutes(apiConfig, ctx)
43- routes = append(routes, rts...)
44- }
45+ } */
46+
47+ routes := NewWebRouter(
48+ apiConfig.Cfg,
49+ logger,
50+ apiConfig.Dbpool,
51+ apiConfig.Storage,
52+ apiConfig.AnalyticsQueue,
53+ )
54+ tunnelRouter := TunnelWebRouter{routes}
55+ router := http.NewServeMux()
56+ router.HandleFunc("GET /{fname}/{options}...", tunnelRouter.ImageRequest)
57+ router.HandleFunc("GET /{fname}", tunnelRouter.AssetRequest)
58+ router.HandleFunc("GET /{$}", tunnelRouter.AssetRequest)
59
60- subdomainRoutes := createSubdomainRoutes(allowPerm)
61+ /* subdomainRoutes := createSubdomainRoutes(allowPerm)
62 routes = append(routes, subdomainRoutes...)
63 finctx := apiConfig.CreateCtx(context.Background(), subdomain)
64 finctx = context.WithValue(finctx, shared.CtxSshKey{}, ctx)
65 httpHandler := shared.CreateServeBasic(routes, finctx)
66- httpRouter := http.HandlerFunc(httpHandler)
67- return httpRouter
68+ httpRouter := http.HandlerFunc(httpHandler) */
69+ return router
70 }
71 }
+2,
-2
1@@ -272,7 +272,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
2
3 // track visit
4 ch := shared.GetAnalyticsQueue(r)
5- view, err := shared.AnalyticsVisitFromRequest(r, user.ID, cfg.Secret)
6+ view, err := shared.AnalyticsVisitFromRequest(r, dbpool, user.ID, cfg.Secret)
7 if err == nil {
8 ch <- view
9 } else {
10@@ -426,7 +426,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
11 }
12
13 // track visit
14- view, err := shared.AnalyticsVisitFromRequest(r, user.ID, cfg.Secret)
15+ view, err := shared.AnalyticsVisitFromRequest(r, dbpool, user.ID, cfg.Secret)
16 if err == nil {
17 view.PostID = post.ID
18 ch <- view
1@@ -79,8 +79,7 @@ func cleanReferer(ref string) (string, error) {
2
3 var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled")
4
5-func AnalyticsVisitFromRequest(r *http.Request, userID string, secret string) (*db.AnalyticsVisits, error) {
6- dbpool := GetDB(r)
7+func AnalyticsVisitFromRequest(r *http.Request, dbpool db.DB, userID string, secret string) (*db.AnalyticsVisits, error) {
8 if !dbpool.HasFeatureForUser(userID, "analytics") {
9 return nil, ErrAnalyticsDisabled
10 }
1@@ -64,7 +64,7 @@ type ApiConfig struct {
2
3 func (hc *ApiConfig) CreateCtx(prevCtx context.Context, subdomain string) context.Context {
4 ctx := context.WithValue(prevCtx, ctxLoggerKey{}, hc.Cfg.Logger)
5- ctx = context.WithValue(ctx, ctxSubdomainKey{}, subdomain)
6+ ctx = context.WithValue(ctx, CtxSubdomainKey{}, subdomain)
7 ctx = context.WithValue(ctx, ctxDBKey{}, hc.Dbpool)
8 ctx = context.WithValue(ctx, ctxStorageKey{}, hc.Storage)
9 ctx = context.WithValue(ctx, ctxCfg{}, hc.Cfg)
10@@ -105,30 +105,29 @@ func CreateServeBasic(routes []Route, ctx context.Context) http.HandlerFunc {
11 }
12 }
13
14-func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *ConfigSite) ([]Route, string) {
15- var subdomain string
16- curRoutes := routes
17-
18- if cfg.IsCustomdomains() || cfg.IsSubdomains() {
19- hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
20- appDomain := strings.ToLower(strings.Split(cfg.Domain, ":")[0])
21-
22- if hostDomain != appDomain {
23- if strings.Contains(hostDomain, appDomain) {
24- subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
25- if subdomain != "" {
26- curRoutes = subdomainRoutes
27- }
28- } else {
29- subdomain = GetCustomDomain(hostDomain, cfg.Space)
30- if subdomain != "" {
31- curRoutes = subdomainRoutes
32- }
33- }
34+func GetSubdomainFromRequest(r *http.Request, domain, space string) string {
35+ hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
36+ appDomain := strings.ToLower(strings.Split(domain, ":")[0])
37+
38+ if hostDomain != appDomain {
39+ if strings.Contains(hostDomain, appDomain) {
40+ subdomain := strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
41+ return subdomain
42+ } else {
43+ subdomain := GetCustomDomain(hostDomain, space)
44+ return subdomain
45 }
46 }
47
48- return curRoutes, subdomain
49+ return ""
50+}
51+
52+func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *ConfigSite) ([]Route, string) {
53+ subdomain := GetSubdomainFromRequest(r, cfg.Domain, cfg.Space)
54+ if subdomain == "" {
55+ return routes, subdomain
56+ }
57+ return subdomainRoutes, subdomain
58 }
59
60 func CreateServe(routes []Route, subdomainRoutes []Route, apiConfig *ApiConfig) http.HandlerFunc {
61@@ -146,7 +145,7 @@ type ctxLoggerKey struct{}
62 type ctxCfg struct{}
63 type ctxAnalyticsQueue struct{}
64
65-type ctxSubdomainKey struct{}
66+type CtxSubdomainKey struct{}
67 type ctxKey struct{}
68 type CtxSshKey struct{}
69
70@@ -183,7 +182,7 @@ func GetField(r *http.Request, index int) string {
71 }
72
73 func GetSubdomain(r *http.Request) string {
74- return r.Context().Value(ctxSubdomainKey{}).(string)
75+ return r.Context().Value(CtxSubdomainKey{}).(string)
76 }
77
78 func GetCustomDomain(host string, space string) string {