repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

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
M imgs/api.go
+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) {
M pgs/api.go
+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(),
A pgs/api_asset_handler.go
+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+}
M pgs/api_test.go
+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 }
M pgs/tunnel.go
+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 }
M prose/api.go
+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
M shared/analytics.go
+1, -2
 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 	}
M shared/router.go
+23, -24
 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 {