repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2026-03-05

web.go

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