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