- commit
- e01669e
- parent
- 4429411
- author
- Mac Chaffee
- date
- 2024-12-04 15:15:01 +0000 UTC
feat(pgs): http caching with souin (#159)
7 files changed,
+111,
-10
+3,
-1
1@@ -101,7 +101,7 @@ FEEDS_DOMAIN=feeds.dev.pico.sh:3004
2 FEEDS_PROTOCOL=http
3 FEEDS_DEBUG=1
4
5-PGS_CADDYFILE=./caddy/Caddyfile
6+PGS_CADDYFILE=./caddy/Caddyfile.pgs
7 PGS_V4=
8 PGS_V6=
9 PGS_HTTP_V4=$PGS_V4:80
10@@ -118,6 +118,8 @@ PGS_DOMAIN=pgs.dev.pico.sh:3005
11 PGS_PROTOCOL=http
12 PGS_STORAGE_DIR=.storage
13 PGS_DEBUG=1
14+PGS_CACHE_USER=testuser
15+PGS_CACHE_PASSWORD=password
16
17 PICO_CADDYFILE=./caddy/Caddyfile.pico
18 PICO_V4=
+21,
-0
1@@ -8,6 +8,14 @@
2 metrics
3 trusted_proxies static 0.0.0.0/0
4 }
5+ cache {
6+ ttl 300s
7+ max_cacheable_body_bytes 1000000
8+ otter
9+ api {
10+ souin
11+ }
12+ }
13 }
14
15 *.{$APP_DOMAIN}, {$APP_DOMAIN} {
16@@ -21,6 +29,19 @@
17 dns cloudflare {$CF_API_TOKEN}
18 resolvers 1.1.1.1
19 }
20+ route {
21+ @souinApi path /souin-api/*
22+ basic_auth @souinApi {
23+ testuser $2a$14$i1G0lil5qti7qahb4.Kte.wP/3O8uaStduzhBBtuDUZhMJeSjxbqm
24+ }
25+ cache {
26+ regex {
27+ exclude /check
28+ }
29+ }
30+ reverse_proxy web:3000
31+ }
32+
33 encode zstd gzip
34
35 header {
+3,
-1
1@@ -8,7 +8,9 @@ ARG TARGETARCH
2 ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
3
4 RUN xcaddy build \
5- --with github.com/caddy-dns/cloudflare
6+ --with github.com/caddy-dns/cloudflare \
7+ --with github.com/darkweak/souin/plugins/caddy@v1.7.5 \
8+ --with github.com/darkweak/storages/otter/caddy
9
10 FROM caddy:alpine
11
+4,
-0
1@@ -16,6 +16,8 @@ func NewConfigSite() *shared.ConfigSite {
2 port := utils.GetEnv("PGS_WEB_PORT", "3000")
3 protocol := utils.GetEnv("PGS_PROTOCOL", "https")
4 storageDir := utils.GetEnv("PGS_STORAGE_DIR", ".storage")
5+ pgsCacheUser := utils.GetEnv("PGS_CACHE_USER", "")
6+ pgsCachePass := utils.GetEnv("PGS_CACHE_PASSWORD", "")
7 minioURL := utils.GetEnv("MINIO_URL", "")
8 minioUser := utils.GetEnv("MINIO_ROOT_USER", "")
9 minioPass := utils.GetEnv("MINIO_ROOT_PASSWORD", "")
10@@ -27,6 +29,8 @@ func NewConfigSite() *shared.ConfigSite {
11 Protocol: protocol,
12 DbURL: dbURL,
13 StorageDir: storageDir,
14+ CacheUser: pgsCacheUser,
15+ CachePassword: pgsCachePass,
16 MinioURL: minioURL,
17 MinioUser: minioUser,
18 MinioPass: minioPass,
+70,
-8
1@@ -6,11 +6,13 @@ import (
2 "io"
3 "io/fs"
4 "log/slog"
5+ "net/http"
6 "os"
7 "path"
8 "path/filepath"
9 "slices"
10 "strings"
11+ "sync"
12 "time"
13
14 "github.com/charmbracelet/ssh"
15@@ -97,16 +99,21 @@ type FileData struct {
16 }
17
18 type UploadAssetHandler struct {
19- DBPool db.DB
20- Cfg *shared.ConfigSite
21- Storage sst.ObjectStorage
22+ DBPool db.DB
23+ Cfg *shared.ConfigSite
24+ Storage sst.ObjectStorage
25+ CacheClearingQueue chan string
26 }
27
28 func NewUploadAssetHandler(dbpool db.DB, cfg *shared.ConfigSite, storage sst.ObjectStorage) *UploadAssetHandler {
29+ // Enable buffering so we don't slow down uploads.
30+ ch := make(chan string, 100)
31+ go runCacheQueue(ch, cfg)
32 return &UploadAssetHandler{
33- DBPool: dbpool,
34- Cfg: cfg,
35- Storage: storage,
36+ DBPool: dbpool,
37+ Cfg: cfg,
38+ Storage: storage,
39+ CacheClearingQueue: ch,
40 }
41 }
42
43@@ -405,6 +412,7 @@ func (h *UploadAssetHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (s
44 utils.BytesToGB(maxSize),
45 (float32(nextStorageSize)/float32(maxSize))*100,
46 )
47+ h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName)
48
49 return str, nil
50 }
51@@ -471,8 +479,9 @@ func (h *UploadAssetHandler) Delete(s ssh.Session, entry *sendutils.FileEntry) e
52 return err
53 }
54 }
55-
56- return h.Storage.DeleteObject(bucket, assetFilepath)
57+ err = h.Storage.DeleteObject(bucket, assetFilepath)
58+ h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName)
59+ return err
60 }
61
62 func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
63@@ -518,3 +527,56 @@ func (h *UploadAssetHandler) writeAsset(reader io.Reader, data *FileData) (int64
64 )
65 return fsize, err
66 }
67+
68+// runCacheQueue processes requests to purge the cache for a single site.
69+// One message arrives per file that is written/deleted during uploads.
70+// Repeated messages for the same site are grouped so that we only flush once
71+// per site per 5 seconds.
72+func runCacheQueue(ch chan string, cfg *shared.ConfigSite) {
73+ cacheApiUrl := fmt.Sprintf("https://%s/souin-api/souin/", cfg.Domain)
74+ var pendingFlushes sync.Map
75+ tick := time.Tick(5 * time.Second)
76+ for {
77+ select {
78+ case host := <-ch:
79+ pendingFlushes.Store(host, host)
80+ case <-tick:
81+ go func() {
82+ pendingFlushes.Range(func(key, value any) bool {
83+ pendingFlushes.Delete(key)
84+ err := purgeCache(key.(string), cacheApiUrl, cfg.CacheUser, cfg.CachePassword)
85+ if err != nil {
86+ cfg.Logger.Error("failed to clear cache", "err", err.Error())
87+ }
88+ return true
89+ })
90+ }()
91+ }
92+ }
93+}
94+
95+// purgeCache send an HTTP request to the pgs Caddy instance which purges
96+// cached entries for a given subdomain (like "fakeuser-www-proj"). We set a
97+// "surrogate-key: <subdomain>" header on every pgs response which ensures all
98+// cached assets for a given subdomain are grouped under a single key (which is
99+// separate from the "GET-https-example.com-/path" key used for serving files
100+// from the cache).
101+func purgeCache(subdomain string, cacheApiUrl string, username string, password string) error {
102+ client := &http.Client{
103+ Timeout: time.Second * 5,
104+ }
105+ req, err := http.NewRequest("PURGE", cacheApiUrl, nil)
106+ if err != nil {
107+ return err
108+ }
109+ req.Header.Add("Surrogate-Key", subdomain)
110+ req.SetBasicAuth(username, password)
111+ resp, err := client.Do(req)
112+ if err != nil {
113+ return err
114+ }
115+ if resp.StatusCode != 204 {
116+ return fmt.Errorf("received unexpected response code %d", resp.StatusCode)
117+ }
118+ return nil
119+}
+8,
-0
1@@ -121,6 +121,11 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2 r.Host = destUrl.Host
3 r.URL = destUrl
4 }
5+ // Disable caching
6+ proxy.ModifyResponse = func(r *http.Response) error {
7+ r.Header.Set("cache-control", "no-cache")
8+ return nil
9+ }
10 proxy.ServeHTTP(w, r)
11 return
12 }
13@@ -216,6 +221,9 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
14 w.Header().Set("content-type", contentType)
15 }
16
17+ // Allows us to invalidate the cache when files are modified
18+ w.Header().Set("surrogate-key", h.Subdomain)
19+
20 finContentType := w.Header().Get("content-type")
21
22 logger.Info(
1@@ -38,6 +38,8 @@ type ConfigSite struct {
2 Protocol string
3 DbURL string
4 StorageDir string
5+ CacheUser string
6+ CachePassword string
7 MinioURL string
8 MinioUser string
9 MinioPass string