// Copyright (C) 2023 The Syncthing Authors. // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. package httpcache import ( "bytes" "compress/gzip" "context" "fmt" "net/http" "strings" "sync" "time" ) type SinglePathCache struct { next http.Handler keep time.Duration mut sync.RWMutex resp *recordedResponse } func SinglePath(next http.Handler, keep time.Duration) *SinglePathCache { return &SinglePathCache{ next: next, keep: keep, } } type recordedResponse struct { status int header http.Header data []byte gzip []byte when time.Time keep time.Duration } func (resp *recordedResponse) ServeHTTP(w http.ResponseWriter, r *http.Request) { for k, v := range resp.header { w.Header()[k] = v } w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(resp.keep.Seconds()))) if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Length", fmt.Sprint(len(resp.gzip))) w.WriteHeader(resp.status) _, _ = w.Write(resp.gzip) return } w.Header().Set("Content-Length", fmt.Sprint(len(resp.data))) w.WriteHeader(resp.status) _, _ = w.Write(resp.data) } type responseRecorder struct { resp *recordedResponse } func (r *responseRecorder) WriteHeader(status int) { r.resp.status = status } func (r *responseRecorder) Write(data []byte) (int, error) { r.resp.data = append(r.resp.data, data...) return len(data), nil } func (r *responseRecorder) Header() http.Header { return r.resp.header } func (s *SinglePathCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { s.next.ServeHTTP(w, r) return } w.Header().Set("X-Cache", "MISS") s.mut.RLock() ok := s.serveCached(w, r) s.mut.RUnlock() if ok { return } s.mut.Lock() defer s.mut.Unlock() if s.serveCached(w, r) { return } rec := &recordedResponse{status: http.StatusOK, header: make(http.Header), when: time.Now(), keep: s.keep} childRec := r.Clone(context.Background()) childRec.Header.Del("Accept-Encoding") // don't let the client dictate the encoding s.next.ServeHTTP(&responseRecorder{resp: rec}, childRec) if rec.status == http.StatusOK { buf := new(bytes.Buffer) gw := gzip.NewWriter(buf) _, _ = gw.Write(rec.data) gw.Close() rec.gzip = buf.Bytes() s.resp = rec } rec.ServeHTTP(w, r) } func (s *SinglePathCache) serveCached(w http.ResponseWriter, r *http.Request) bool { if s.resp == nil || time.Since(s.resp.when) > s.keep { return false } w.Header().Set("X-Cache", "HIT") w.Header().Set("X-Cache-From", s.resp.when.Format(time.RFC3339)) s.resp.ServeHTTP(w, r) return true }