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