repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2026-01-25

web.go

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