repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

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
M .env.example
+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=
M caddy/Caddyfile.pgs
+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 {
M caddy/Dockerfile
+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 
M pgs/config.go
+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,
M pgs/uploader.go
+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+}
M pgs/web_asset_handler.go
+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(
M shared/config.go
+2, -0
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