repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
Eric Bower  ·  2026-04-22

web.go

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