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}