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}