main pico / pkg / apps / pgs / web.go
Antonio Mika  ·  2026-10-25
  1package pgs
  2
  3import (
  4	"bufio"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"log/slog"
  9	"net/http"
 10	"path/filepath"
 11	"regexp"
 12	"strings"
 13	"time"
 14
 15	_ "net/http/pprof"
 16
 17	"github.com/gorilla/feeds"
 18	"github.com/hashicorp/golang-lru/v2/expirable"
 19	"github.com/picosh/pico/pkg/db"
 20	"github.com/picosh/pico/pkg/httpcache"
 21	"github.com/picosh/pico/pkg/metrics"
 22	"github.com/picosh/pico/pkg/shared"
 23	"github.com/picosh/pico/pkg/shared/router"
 24	"github.com/picosh/pico/pkg/storage"
 25	"github.com/prometheus/client_golang/prometheus"
 26	"github.com/prometheus/client_golang/prometheus/promauto"
 27	"github.com/prometheus/client_golang/prometheus/promhttp"
 28)
 29
 30type PgsCacheKey struct {
 31	Domain    string
 32	TxtPrefix string
 33}
 34
 35func (c *PgsCacheKey) GetCacheKey(r *http.Request) string {
 36	subdomain := router.GetSubdomainFromRequest(r, c.Domain, c.TxtPrefix)
 37	// RFC 9111 ยง3: HEAD responses can be served from a stored GET response.
 38	method := r.Method
 39	if method == http.MethodHead {
 40		method = http.MethodGet
 41	}
 42	return subdomain + "__" + method + "__" + r.URL.RequestURI()
 43}
 44
 45type PromCacheMetrics struct {
 46	Logger         *slog.Logger
 47	Cache          httpcache.Cacher
 48	CacheItems     prometheus.Gauge
 49	CacheSizeBytes prometheus.Gauge
 50	CacheHit       prometheus.Counter
 51	CacheMiss      prometheus.Counter
 52	UpstreamReq    prometheus.Counter
 53}
 54
 55func NewPromCacheMetrics(logger *slog.Logger, reg prometheus.Registerer) *PromCacheMetrics {
 56	name := "pgs"
 57	auto := promauto.With(reg)
 58	return &PromCacheMetrics{
 59		Logger: logger,
 60		CacheItems: auto.NewGauge(prometheus.GaugeOpts{
 61			Namespace: name,
 62			Subsystem: "http_cache",
 63			Name:      "total_items",
 64			Help:      "Number of items in the http cache",
 65		}),
 66		CacheSizeBytes: auto.NewGauge(prometheus.GaugeOpts{
 67			Namespace: name,
 68			Subsystem: "http_cache",
 69			Name:      "total_size_bytes",
 70			Help:      "The total size of the http cache in bytes",
 71		}),
 72		CacheHit: auto.NewCounter(prometheus.CounterOpts{
 73			Namespace: name,
 74			Subsystem: "http_cache",
 75			Name:      "cache_hit_count",
 76			Help:      "The number of times there was a cache hit",
 77		}),
 78		CacheMiss: auto.NewCounter(prometheus.CounterOpts{
 79			Namespace: name,
 80			Subsystem: "http_cache",
 81			Name:      "cache_miss_count",
 82			Help:      "The number of times there was a cache miss",
 83		}),
 84		UpstreamReq: auto.NewCounter(prometheus.CounterOpts{
 85			Namespace: name,
 86			Subsystem: "http_cache",
 87			Name:      "upstream_request_count",
 88			Help:      "The number of times the upstream http server was requested",
 89		}),
 90	}
 91}
 92func (p *PromCacheMetrics) AddCacheItem(size float64) {
 93	p.CacheItems.Add(1)
 94	p.CacheSizeBytes.Add(size)
 95}
 96func (p *PromCacheMetrics) EvictCacheItem(key string, value []byte) {
 97	p.Logger.Info("evicting cache key", "key", key, "len_bytes", len(value))
 98	p.CacheItems.Add(-1)
 99	p.CacheSizeBytes.Add(-float64(len(value)))
100}
101func (p *PromCacheMetrics) AddCacheHit() {
102	p.CacheHit.Add(1)
103}
104func (p *PromCacheMetrics) AddCacheMiss() {
105	p.CacheMiss.Add(1)
106}
107func (p *PromCacheMetrics) AddUpstreamRequest() {
108	p.UpstreamReq.Add(1)
109}
110
111func NewPgsHttpCache(cfg *Config, upstream http.Handler) (*httpcache.HttpCache, *SiteCache) {
112	ttl := cfg.CacheTTL
113	metrics := NewPromCacheMetrics(cfg.Logger, prometheus.DefaultRegisterer)
114	cache := NewSiteCache(cfg.CacheMaxItems, ttl, metrics.EvictCacheItem)
115	httpCache := &httpcache.HttpCache{
116		Ttl:      ttl,
117		Logger:   cfg.Logger,
118		Upstream: upstream,
119		Cache:    cache,
120		CacheKey: &PgsCacheKey{
121			Domain:    cfg.Domain,
122			TxtPrefix: cfg.TxtPrefix,
123		},
124		CacheMetrics: metrics,
125		MaxBodySize:  cfg.CacheMaxBodySize,
126	}
127	httpCache.Logger.Info(
128		"httpcache initiated",
129		"storageType", "expirable.LRU",
130		"ttl", ttl,
131		"maxItems", cfg.CacheMaxItems,
132		"maxBodySize", cfg.CacheMaxBodySize,
133	)
134	return httpCache, cache
135}
136
137// NewSiteMetrics counts requests for each site owner and project. Both come
138// from the host, so responses served from a cache count too.
139func NewSiteMetrics(cfg *Config, app string, trustSuccess bool) *router.UserMetrics {
140	return router.NewUserMetrics(app, prometheus.DefaultRegisterer, router.UserMetricsOpts{
141		Owner:        siteProject(cfg),
142		TrustSuccess: trustSuccess,
143	})
144}
145
146func siteProject(cfg *Config) func(r *http.Request) (string, string) {
147	return func(r *http.Request) (string, string) {
148		subdomain := router.GetSubdomainFromRequest(r, cfg.Domain, cfg.TxtPrefix)
149		if subdomain == "" {
150			return "", ""
151		}
152		props, _ := router.GetProjectFromSubdomain(subdomain)
153		return props.Username, props.ProjectName
154	}
155}
156
157func StartApiServer(cfg *Config) {
158	ctx := context.Background()
159
160	router := NewWebRouter(cfg)
161	httpCache, cache := NewPgsHttpCache(router.Cfg, router)
162	go CacheMgmt(ctx, cfg, cache, router.clearSiteCache)
163
164	portStr := fmt.Sprintf(":%s", cfg.WebPort)
165	cfg.Logger.Info(
166		"starting server on port",
167		"port", cfg.WebPort,
168		"domain", cfg.Domain,
169	)
170	metrics.RegisterServerInfo(prometheus.DefaultRegisterer, "pgs")
171	err := http.ListenAndServe(portStr, NewSiteMetrics(cfg, "pgs", false).Handler(httpCache))
172	cfg.Logger.Error(
173		"listen and serve",
174		"err", err.Error(),
175	)
176}
177
178type HasPerm = func(proj *db.Project) bool
179
180type WebRouter struct {
181	Cfg            *Config
182	RootRouter     *http.ServeMux
183	UserRouter     *http.ServeMux
184	RedirectsCache *expirable.LRU[string, []*RedirectRule]
185	HeadersCache   *expirable.LRU[string, []headerMatcher]
186	SiteCache      *expirable.LRU[string, *siteInfo]
187}
188
189// siteInfo is what serving a site needs from the database and storage.
190type siteInfo struct {
191	user    *db.User
192	project *db.Project
193	bucket  storage.Bucket
194	plus    *db.FeatureFlag
195}
196
197func (s *siteInfo) hasPicoPlus() bool {
198	return s.plus != nil && s.plus.ExpiresAt.After(time.Now())
199}
200
201func NewWebRouter(cfg *Config) *WebRouter {
202	return newWebRouter(cfg)
203}
204
205func newWebRouter(cfg *Config) *WebRouter {
206	router := &WebRouter{
207		Cfg:            cfg,
208		RedirectsCache: expirable.NewLRU[string, []*RedirectRule](2048, nil, shared.CacheTimeout),
209		HeadersCache:   expirable.NewLRU[string, []headerMatcher](2048, nil, shared.CacheTimeout),
210		SiteCache:      expirable.NewLRU[string, *siteInfo](2048, nil, shared.CacheTimeout),
211	}
212	router.initRouters()
213	return router
214}
215
216func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
217	if web.RootRouter == nil || web.UserRouter == nil {
218		web.Cfg.Logger.Error("routers not initialized")
219		http.Error(w, "routers not initialized", http.StatusInternalServerError)
220		return
221	}
222	router.NewHostRouter(web.Cfg.Domain, web.Cfg.TxtPrefix, web.RootRouter, web.UserRouter).ServeHTTP(w, r)
223}
224
225// clearSiteCache drops what is cached about a site: its database records and
226// its parsed _redirects and _headers. The key is the surrogate key that
227// uploads and the CLI purge, or "*" for every site.
228func (web *WebRouter) clearSiteCache(key string) {
229	web.Cfg.Logger.Info("lru cache clear request", "key", key)
230	if key == "*" {
231		web.SiteCache.Purge()
232		web.RedirectsCache.Purge()
233		web.HeadersCache.Purge()
234		return
235	}
236	web.SiteCache.Remove(key)
237	web.RedirectsCache.Remove(filepath.Join(key, "_redirects"))
238	web.HeadersCache.Remove(filepath.Join(key, "_headers"))
239}
240
241// findSite looks up the user, project, bucket and pico+ flag behind a
242// subdomain. Only complete results are cached, so a site that is created or
243// fixed later is found on its next request.
244func (web *WebRouter) findSite(props *router.SubdomainProps, logger *slog.Logger) (*siteInfo, int, string) {
245	key := getSurrogateKey(props.Username, props.ProjectName)
246	if site, ok := web.SiteCache.Get(key); ok {
247		return site, http.StatusOK, ""
248	}
249
250	user, err := web.Cfg.DB.FindUserByName(props.Username)
251	if err != nil {
252		logger.Info("user not found")
253		return nil, http.StatusNotFound, "user not found"
254	}
255	project, err := web.Cfg.DB.FindProjectByName(user.ID, props.ProjectName)
256	if err != nil {
257		logger.Info("project not found")
258		return nil, http.StatusNotFound, "project not found"
259	}
260	bucket, err := web.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
261	if err != nil {
262		logger.Error("bucket not found", "err", err)
263		return nil, http.StatusNotFound, "bucket not found"
264	}
265	plus, _ := web.Cfg.DB.FindFeature(user.ID, "plus")
266
267	site := &siteInfo{user: user, project: project, bucket: bucket, plus: plus}
268	web.SiteCache.Add(key, site)
269	return site, http.StatusOK, ""
270}
271
272func (web *WebRouter) initRouters() {
273	// ensure legacy router is disabled
274	// GODEBUG=httpmuxgo121=0
275
276	// root domain
277	rootRouter := http.NewServeMux()
278	rootRouter.HandleFunc("GET /check", web.checkHandler)
279	rootRouter.HandleFunc("GET /_metrics", func(w http.ResponseWriter, r *http.Request) {
280		// we do *not* want to cache this handler
281		w.Header().Set("cache-control", "no-store")
282		promhttp.Handler().ServeHTTP(w, r)
283	})
284	files := web.Cfg.Static()
285	rootRouter.Handle("GET /main.css", files.File("main.css", "text/css"))
286	rootRouter.Handle("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
287	rootRouter.Handle("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
288	rootRouter.Handle("GET /robots.txt", files.File("robots.txt", "text/plain"))
289
290	rootRouter.Handle("GET /rss/updated", web.createRssHandler("updated_at"))
291	rootRouter.Handle("GET /rss", web.createRssHandler("created_at"))
292	rootRouter.Handle("GET /{$}", files.Page("html/marketing.page.tmpl"))
293	web.RootRouter = rootRouter
294
295	// subdomain or custom domains
296	userRouter := http.NewServeMux()
297	userRouter.HandleFunc("POST /pgs/login", web.handleLogin)
298	userRouter.HandleFunc("POST /pgs/forms/{fname...}", web.handleAutoForm)
299	userRouter.HandleFunc("GET /{fname...}", web.AssetRequest(WebPerm))
300	userRouter.HandleFunc("GET /{$}", web.AssetRequest(WebPerm))
301	web.UserRouter = userRouter
302}
303
304func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
305	dbpool := web.Cfg.DB
306	cfg := web.Cfg
307	logger := web.Cfg.Logger
308
309	hostDomain := r.URL.Query().Get("domain")
310	if hostDomain == "" {
311		w.WriteHeader(http.StatusNotFound)
312		return
313	}
314	appDomain := strings.Split(cfg.Domain, ":")[0]
315
316	// we do *not* want to cache this handler
317	w.Header().Set("cache-control", "no-store")
318
319	if !strings.Contains(hostDomain, appDomain) {
320		subdomain := router.GetCustomDomain(hostDomain, cfg.TxtPrefix)
321		props, err := router.GetProjectFromSubdomain(subdomain)
322		if err != nil {
323			logger.Error(
324				"could not get project from subdomain",
325				"subdomain", subdomain,
326				"err", err.Error(),
327			)
328			w.WriteHeader(http.StatusNotFound)
329			return
330		}
331
332		u, err := dbpool.FindUserByName(props.Username)
333		if err != nil {
334			logger.Error("could not find user", "err", err.Error())
335			w.WriteHeader(http.StatusNotFound)
336			return
337		}
338
339		logger = logger.With(
340			"user", u.Name,
341			"project", props.ProjectName,
342		)
343		p, err := dbpool.FindProjectByName(u.ID, props.ProjectName)
344		if err != nil {
345			logger.Error(
346				"could not find project for user",
347				"user", u.Name,
348				"project", props.ProjectName,
349				"err", err.Error(),
350			)
351			w.WriteHeader(http.StatusNotFound)
352			return
353		}
354
355		if u != nil && p != nil {
356			w.WriteHeader(http.StatusOK)
357			return
358		}
359	}
360
361	w.WriteHeader(http.StatusNotFound)
362}
363
364// CacheMgmt applies the purges published on the cache-drain pipe. onPurge,
365// when set, clears whatever else is cached about the site; it runs first so
366// the http cache isn't refilled from stale records.
367func CacheMgmt(ctx context.Context, cfg *Config, cache *SiteCache, onPurge func(site string)) {
368	cfg.Logger.Info("cache mgmt initiated")
369	for {
370		scanner := bufio.NewScanner(cfg.Pubsub)
371		scanner.Buffer(make([]byte, 32*1024), 32*1024)
372		for scanner.Scan() {
373			subdomain := strings.TrimSpace(scanner.Text())
374			cfg.Logger.Info("received cache-drain item", "subdomain", subdomain)
375			if onPurge != nil {
376				onPurge(subdomain)
377			}
378
379			if subdomain == "*" {
380				cache.Purge()
381				cfg.Logger.Info("successfully cleared cache from remote cli request")
382				continue
383			}
384			cache.PurgeSite(subdomain)
385		}
386	}
387}
388
389func (web *WebRouter) createRssHandler(by string) http.HandlerFunc {
390	return func(w http.ResponseWriter, r *http.Request) {
391		dbpool := web.Cfg.DB
392		logger := web.Cfg.Logger
393		cfg := web.Cfg
394
395		projects, err := dbpool.FindProjects(by)
396		if err != nil {
397			logger.Error("could not find projects", "err", err.Error())
398			http.Error(w, err.Error(), http.StatusInternalServerError)
399			return
400		}
401
402		feed := &feeds.Feed{
403			Title:       fmt.Sprintf("%s discovery feed %s", cfg.Domain, by),
404			Link:        &feeds.Link{Href: "https://pgs.sh"},
405			Description: fmt.Sprintf("%s projects %s", cfg.Domain, by),
406			Author:      &feeds.Author{Name: cfg.Domain},
407			Created:     time.Now(),
408		}
409
410		var feedItems []*feeds.Item
411		for _, project := range projects {
412			realUrl := strings.TrimSuffix(
413				cfg.AssetURL(project.Username, project.Name, ""),
414				"/",
415			)
416			uat := project.UpdatedAt.Unix()
417			id := realUrl
418			title := fmt.Sprintf("%s-%s", project.Username, project.Name)
419			if by == "updated_at" {
420				id = fmt.Sprintf("%s:%d", realUrl, uat)
421				title = fmt.Sprintf("%s - %d", title, uat)
422			}
423
424			item := &feeds.Item{
425				Id:          id,
426				Title:       title,
427				Link:        &feeds.Link{Href: realUrl},
428				Content:     fmt.Sprintf(`<a href="%s">%s</a>`, realUrl, realUrl),
429				Created:     *project.CreatedAt,
430				Updated:     *project.CreatedAt,
431				Description: "",
432				Author:      &feeds.Author{Name: project.Username},
433			}
434
435			feedItems = append(feedItems, item)
436		}
437		feed.Items = feedItems
438
439		rss, err := feed.ToAtom()
440		if err != nil {
441			logger.Error("could not convert feed to atom", "err", err.Error())
442			http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
443		}
444
445		w.Header().Add("Content-Type", "application/atom+xml")
446		_, err = w.Write([]byte(rss))
447		if err != nil {
448			logger.Error("http write failed", "err", err.Error())
449		}
450	}
451}
452
453func WebPerm(proj *db.Project) bool {
454	return proj.Acl.Type == "public" || proj.Acl.Type == ""
455}
456
457var imgRegex = regexp.MustCompile(`(.+\.(?:jpg|jpeg|png|gif|webp|svg))(/.+)`)
458
459func (web *WebRouter) AssetRequest(perm func(proj *db.Project) bool) http.HandlerFunc {
460	return func(w http.ResponseWriter, r *http.Request) {
461		fname := r.PathValue("fname")
462		if imgRegex.MatchString(fname) {
463			web.ImageRequest(perm)(w, r)
464			return
465		}
466		web.ServeAsset(fname, nil, perm, w, r)
467	}
468}
469
470func (web *WebRouter) ImageRequest(perm func(proj *db.Project) bool) http.HandlerFunc {
471	return func(w http.ResponseWriter, r *http.Request) {
472		rawname := r.PathValue("fname")
473		matches := imgRegex.FindStringSubmatch(rawname)
474		fname := rawname
475		imgOpts := ""
476		if len(matches) >= 2 {
477			fname = matches[1]
478		}
479		if len(matches) >= 3 {
480			imgOpts = matches[2]
481		}
482
483		opts, err := storage.UriToImgProcessOpts(imgOpts)
484		if err != nil {
485			errMsg := fmt.Sprintf("ERROR: error processing img options: %s", err.Error())
486			web.Cfg.Logger.Error("ERROR: processing img options", "err", errMsg)
487			http.Error(w, errMsg, http.StatusUnprocessableEntity)
488			return
489		}
490
491		web.ServeAsset(fname, opts, perm, w, r)
492	}
493}
494
495func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
496	subdomain := router.GetSubdomain(r)
497
498	logger := web.Cfg.Logger.With(
499		"subdomain", subdomain,
500		"filename", fname,
501		"url", fmt.Sprintf("%s%s", r.Host, r.URL.Path),
502		"host", r.Host,
503	)
504
505	props, err := router.GetProjectFromSubdomain(subdomain)
506	if err != nil {
507		logger.Info(
508			"could not determine project from subdomain",
509			"err", err,
510		)
511		http.Error(w, err.Error(), http.StatusNotFound)
512		return
513	}
514
515	logger = logger.With(
516		"project", props.ProjectName,
517		"user", props.Username,
518	)
519
520	site, status, msg := web.findSite(props, logger)
521	if site == nil {
522		http.Error(w, msg, status)
523		return
524	}
525	router.SetUser(r, site.user.Name)
526	// The name from the host, so cache hits for it count under the same
527	// project.
528	router.SetResource(r, props.ProjectName)
529	user, project, bucket := site.user, site.project, site.bucket
530
531	logger = logger.With(
532		"userId", user.ID,
533		"projectId", project.ID,
534		"project", project.Name,
535	)
536
537	if project.Blocked != "" {
538		logger.Error("project has been blocked")
539		http.Error(w, project.Blocked, http.StatusForbidden)
540		return
541	}
542
543	if project.Acl.Type == "http-pass" {
544		cookie, err := r.Cookie(getCookieName(project.Name))
545		if err == nil {
546			if cookie.Valid() != nil || cookie.Value != project.ID {
547				logger.Error("cookie not valid", "err", err)
548				web.serveLoginForm(w, r, project, logger)
549				return
550			}
551		} else {
552			if errors.Is(err, http.ErrNoCookie) {
553				web.serveLoginForm(w, r, project, logger)
554				return
555			} else {
556				// Some other error occurred
557				logger.Error("failed to fetch cookie", "err", err)
558				http.Error(w, err.Error(), http.StatusInternalServerError)
559				return
560			}
561		}
562	} else if !hasPerm(project) {
563		logger.Error("You do not have access to this site")
564		http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
565		return
566	}
567
568	asset := &ApiAssetHandler{
569		WebRouter: web,
570		Logger:    logger,
571
572		Username:       props.Username,
573		UserID:         user.ID,
574		Subdomain:      subdomain,
575		ProjectID:      project.ID,
576		ProjectDir:     project.ProjectDir,
577		Filepath:       fname,
578		Bucket:         bucket,
579		ImgProcessOpts: opts,
580		HasPicoPlus:    site.hasPicoPlus(),
581		HttpPass:       project.Acl.Type == "http-pass",
582	}
583
584	asset.ServeHTTP(w, r)
585}
586
587func (web *WebRouter) serveLoginForm(w http.ResponseWriter, r *http.Request, project *db.Project, logger *slog.Logger) {
588	serveLoginFormWithConfig(w, r, project, web.Cfg, logger)
589}
590
591func (web *WebRouter) handleLogin(w http.ResponseWriter, r *http.Request) {
592	handleLogin(w, r, web.Cfg)
593}
594
595func (web *WebRouter) handleAutoForm(w http.ResponseWriter, r *http.Request) {
596	handleAutoForm(w, r, web.Cfg)
597}