diff --git a/.env.example b/.env.example index cd56239e..1de8bede 100644 --- a/.env.example +++ b/.env.example @@ -101,7 +101,7 @@ FEEDS_DOMAIN=feeds.dev.pico.sh:3004 FEEDS_PROTOCOL=http FEEDS_DEBUG=1 -PGS_CADDYFILE=./caddy/Caddyfile +PGS_CADDYFILE=./caddy/Caddyfile.pgs PGS_V4= PGS_V6= PGS_HTTP_V4=$PGS_V4:80 @@ -118,6 +118,8 @@ PGS_DOMAIN=pgs.dev.pico.sh:3005 PGS_PROTOCOL=http PGS_STORAGE_DIR=.storage PGS_DEBUG=1 +PGS_CACHE_USER=testuser +PGS_CACHE_PASSWORD=password PICO_CADDYFILE=./caddy/Caddyfile.pico PICO_V4= diff --git a/caddy/Caddyfile.pgs b/caddy/Caddyfile.pgs index cb251a3c..8f198dd4 100644 --- a/caddy/Caddyfile.pgs +++ b/caddy/Caddyfile.pgs @@ -7,14 +7,34 @@ servers { metrics } + cache { + ttl 300s + max_cacheable_body_bytes 1000000 + otter + api { + souin + } + } } *.{$APP_DOMAIN}, {$APP_DOMAIN} { - reverse_proxy web:3000 tls {$APP_EMAIL} { dns cloudflare {$CF_API_TOKEN} resolvers 1.1.1.1 } + route { + @souinApi path /souin-api/* + basic_auth @souinApi { + testuser $2a$14$i1G0lil5qti7qahb4.Kte.wP/3O8uaStduzhBBtuDUZhMJeSjxbqm + } + cache { + regex { + exclude /check + } + } + reverse_proxy web:3000 + } + encode zstd gzip header { diff --git a/caddy/Dockerfile b/caddy/Dockerfile index 58fb629d..3922a4eb 100644 --- a/caddy/Dockerfile +++ b/caddy/Dockerfile @@ -8,7 +8,9 @@ ARG TARGETARCH ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} RUN xcaddy build \ - --with github.com/caddy-dns/cloudflare + --with github.com/caddy-dns/cloudflare \ + --with github.com/darkweak/souin/plugins/caddy@v1.7.2 \ + --with github.com/darkweak/storages/otter/caddy FROM caddy:alpine diff --git a/filehandlers/assets/handler.go b/filehandlers/assets/handler.go index f57c829e..28c720c4 100644 --- a/filehandlers/assets/handler.go +++ b/filehandlers/assets/handler.go @@ -6,11 +6,13 @@ import ( "io" "io/fs" "log/slog" + "net/http" "os" "path" "path/filepath" "slices" "strings" + "sync" "time" "github.com/charmbracelet/ssh" @@ -98,16 +100,21 @@ type FileData struct { } type UploadAssetHandler struct { - DBPool db.DB - Cfg *shared.ConfigSite - Storage storage.StorageServe + DBPool db.DB + Cfg *shared.ConfigSite + Storage storage.StorageServe + CacheClearingQueue chan string } func NewUploadAssetHandler(dbpool db.DB, cfg *shared.ConfigSite, storage storage.StorageServe) *UploadAssetHandler { + // Enable buffering so we don't slow down uploads. + ch := make(chan string, 100) + go runCacheQueue(ch, cfg) return &UploadAssetHandler{ - DBPool: dbpool, - Cfg: cfg, - Storage: storage, + DBPool: dbpool, + Cfg: cfg, + Storage: storage, + CacheClearingQueue: ch, } } @@ -402,6 +409,7 @@ func (h *UploadAssetHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (s utils.BytesToGB(maxSize), (float32(nextStorageSize)/float32(maxSize))*100, ) + h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName) return str, nil } @@ -468,8 +476,9 @@ func (h *UploadAssetHandler) Delete(s ssh.Session, entry *sendutils.FileEntry) e return err } } - - return h.Storage.DeleteObject(bucket, assetFilepath) + err = h.Storage.DeleteObject(bucket, assetFilepath) + h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName) + return err } func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) { @@ -515,3 +524,56 @@ func (h *UploadAssetHandler) writeAsset(reader io.Reader, data *FileData) (int64 ) return fsize, err } + +// runCacheQueue processes requests to purge the cache for a single site. +// One message arrives per file that is written/deleted during uploads. +// Repeated messages for the same site are grouped so that we only flush once +// per site per 5 seconds. +func runCacheQueue(ch chan string, cfg *shared.ConfigSite) { + cacheApiUrl := fmt.Sprintf("https://%s/souin-api/souin/", cfg.Domain) + var pendingFlushes sync.Map + tick := time.Tick(5 * time.Second) + for { + select { + case host := <-ch: + pendingFlushes.Store(host, host) + case <-tick: + go func() { + pendingFlushes.Range(func(key, value any) bool { + pendingFlushes.Delete(key) + err := purgeCache(key.(string), cacheApiUrl, cfg.CacheUser, cfg.CachePassword) + if err != nil { + cfg.Logger.Error("failed to clear cache", "err", err.Error()) + } + return true + }) + }() + } + } +} + +// purgeCache send an HTTP request to the pgs Caddy instance which purges +// cached entries for a given subdomain (like "fakeuser-www-proj"). We set a +// "surrogate-key: <subdomain>" header on every pgs response which ensures all +// cached assets for a given subdomain are grouped under a single key (which is +// separate from the "GET-https-example.com-/path" key used for serving files +// from the cache). +func purgeCache(subdomain string, cacheApiUrl string, username string, password string) error { + client := &http.Client{ + Timeout: time.Second * 5, + } + req, err := http.NewRequest("PURGE", cacheApiUrl, nil) + if err != nil { + return err + } + req.Header.Add("Surrogate-Key", subdomain) + req.SetBasicAuth(username, password) + resp, err := client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != 204 { + return fmt.Errorf("received unexpected response code %d", resp.StatusCode) + } + return nil +} diff --git a/pgs/api.go b/pgs/api.go index bbbb3675..f0370289 100644 --- a/pgs/api.go +++ b/pgs/api.go @@ -354,6 +354,9 @@ func (h *AssetHandler) handle(logger *slog.Logger, w http.ResponseWriter, r *htt w.Header().Set("content-type", contentType) } + // Allows us to invalidate the cache when files are modified + w.Header().Set("surrogate-key", h.Subdomain) + finContentType := w.Header().Get("content-type") // only track pages, not individual assets diff --git a/pgs/config.go b/pgs/config.go index 3cc9a57f..6b3c6085 100644 --- a/pgs/config.go +++ b/pgs/config.go @@ -16,6 +16,8 @@ func NewConfigSite() *shared.ConfigSite { port := utils.GetEnv("PGS_WEB_PORT", "3000") protocol := utils.GetEnv("PGS_PROTOCOL", "https") storageDir := utils.GetEnv("PGS_STORAGE_DIR", ".storage") + pgsCacheUser := utils.GetEnv("PGS_CACHE_USER", "") + pgsCachePass := utils.GetEnv("PGS_CACHE_PASSWORD", "") minioURL := utils.GetEnv("MINIO_URL", "") minioUser := utils.GetEnv("MINIO_ROOT_USER", "") minioPass := utils.GetEnv("MINIO_ROOT_PASSWORD", "") @@ -32,6 +34,8 @@ func NewConfigSite() *shared.ConfigSite { Protocol: protocol, DbURL: dbURL, StorageDir: storageDir, + CacheUser: pgsCacheUser, + CachePassword: pgsCachePass, MinioURL: minioURL, MinioUser: minioUser, MinioPass: minioPass, diff --git a/shared/config.go b/shared/config.go index 0413e1e5..f566b7fb 100644 --- a/shared/config.go +++ b/shared/config.go @@ -37,6 +37,8 @@ type ConfigSite struct { Protocol string DbURL string StorageDir string + CacheUser string + CachePassword string MinioURL string MinioUser string MinioPass string