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}