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}