mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-18 00:59:38 +00:00
200 lines
6 KiB
Go
200 lines
6 KiB
Go
package server
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/klauspost/compress/zstd"
|
|
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
"github.com/photoprism/photoprism/pkg/http/header"
|
|
)
|
|
|
|
// compressFlusher is the optional interface implemented by encoder writers
|
|
// that buffer output internally and need to be flushed before the underlying
|
|
// http.ResponseWriter is flushed.
|
|
type compressFlusher interface {
|
|
Flush() error
|
|
}
|
|
|
|
// statusBypassesCompression reports whether responses with the given status
|
|
// should be sent uncompressed: 4xx/5xx (tiny error bodies aren't worth the CPU),
|
|
// 206 Partial Content (Range offsets are over the identity representation), and
|
|
// 204/304 (no body to encode). Status 0 means the handler hasn't written yet
|
|
// and defaults to 200, which is compressible.
|
|
func statusBypassesCompression(status int) bool {
|
|
switch {
|
|
case status >= 400:
|
|
return true
|
|
case status == http.StatusPartialContent:
|
|
return true
|
|
case status == http.StatusNoContent, status == http.StatusNotModified:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// weakenETag rewrites a strong ETag to its weak form (W/"…") so caches don't
|
|
// reuse a compressed representation across content-codings. Per RFC 9110
|
|
// §13.2.4 strong validators must change when the bytes change. Already-weak
|
|
// or missing ETags are left as-is.
|
|
func weakenETag(h http.Header) {
|
|
etag := h.Get(header.ETag)
|
|
if etag == "" || strings.HasPrefix(etag, "W/") {
|
|
return
|
|
}
|
|
h.Set(header.ETag, "W/"+etag)
|
|
}
|
|
|
|
// compressWriter wraps a Gin ResponseWriter and routes body writes through the
|
|
// configured encoder. On the first Write it either bypasses the encoder (for
|
|
// 4xx/5xx/204/304 — Content-Encoding is removed) or clears Content-Length so
|
|
// subsequent writes can stream through. `encoded` tracks whether any byte
|
|
// reached the encoder so the middleware can suppress an empty frame trailer.
|
|
type compressWriter struct {
|
|
gin.ResponseWriter
|
|
encoder io.Writer
|
|
encoding string
|
|
headerDone bool
|
|
bypass bool
|
|
encoded bool
|
|
}
|
|
|
|
// Write encodes data through the wrapper or bypasses the encoder for error/
|
|
// bodyless responses. The first call also clears Content-Length, weakens any
|
|
// strong ETag (so caches don't mix encodings), and removes Content-Encoding
|
|
// in the bypass path.
|
|
func (cw *compressWriter) Write(p []byte) (int, error) {
|
|
if !cw.headerDone {
|
|
cw.headerDone = true
|
|
if statusBypassesCompression(cw.Status()) {
|
|
cw.bypass = true
|
|
cw.ResponseWriter.Header().Del(header.ContentEncoding)
|
|
} else {
|
|
cw.ResponseWriter.Header().Del(header.ContentLength)
|
|
weakenETag(cw.Header())
|
|
}
|
|
}
|
|
if cw.bypass {
|
|
return cw.ResponseWriter.Write(p)
|
|
}
|
|
cw.encoded = true
|
|
return cw.encoder.Write(p)
|
|
}
|
|
|
|
// WriteString delegates to Write so bypass and Content-Length-reset logic stays in one place.
|
|
func (cw *compressWriter) WriteString(s string) (int, error) {
|
|
return cw.Write([]byte(s))
|
|
}
|
|
|
|
// Flush forwards to the encoder (when not bypassed) and then to the underlying
|
|
// ResponseWriter so streaming consumers receive partial output.
|
|
func (cw *compressWriter) Flush() {
|
|
if !cw.bypass {
|
|
if f, ok := cw.encoder.(compressFlusher); ok {
|
|
_ = f.Flush()
|
|
}
|
|
}
|
|
cw.ResponseWriter.Flush()
|
|
}
|
|
|
|
// NewCompressMiddleware returns a Gin middleware that negotiates gzip or zstd
|
|
// content-encoding per request based on the operator's preferences and the
|
|
// client's Accept-Encoding header. Connection upgrades, paths excluded by
|
|
// NewShouldCompressFn, HEAD requests, and 4xx/5xx/204/206/304 bodies are
|
|
// skipped. Eligible responses always set `Vary: Accept-Encoding`. Returns a
|
|
// pass-through middleware when conf is nil or no encodings are configured.
|
|
func NewCompressMiddleware(conf *config.Config) gin.HandlerFunc {
|
|
if conf == nil {
|
|
return func(c *gin.Context) { c.Next() }
|
|
}
|
|
|
|
prefs := conf.HttpCompressionPreferences()
|
|
if len(prefs) == 0 {
|
|
return func(c *gin.Context) { c.Next() }
|
|
}
|
|
|
|
shouldCompress := NewShouldCompressFn(conf)
|
|
|
|
gzipPool := &sync.Pool{
|
|
New: func() interface{} {
|
|
gz, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
|
|
return gz
|
|
},
|
|
}
|
|
zstdPool := &sync.Pool{
|
|
New: func() interface{} {
|
|
zw, _ := zstd.NewWriter(io.Discard, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
|
return zw
|
|
},
|
|
}
|
|
|
|
return func(c *gin.Context) {
|
|
if c == nil || c.Request == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Connection upgrades (e.g. WebSockets) must not be wrapped.
|
|
if strings.Contains(strings.ToLower(c.GetHeader("Connection")), "upgrade") {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Path is excluded from compression — pass through and skip Vary too,
|
|
// since the response shape does not depend on Accept-Encoding here.
|
|
if !shouldCompress(c) {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Vary: Accept-Encoding on every eligible response so shared caches keep
|
|
// separate entries even when the negotiator selects identity. Also set
|
|
// on HEAD per RFC 9110 §15.4.5 since the corresponding GET would vary.
|
|
c.Writer.Header().Add(header.Vary, header.AcceptEncoding)
|
|
|
|
// HEAD bodies are discarded by net/http — skip the encoder pool entirely.
|
|
if c.Request.Method == http.MethodHead {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
switch NegotiateEncoding(c.GetHeader(header.AcceptEncoding), prefs) {
|
|
case EncodingGzip:
|
|
gz := gzipPool.Get().(*gzip.Writer)
|
|
gz.Reset(c.Writer)
|
|
c.Header(header.ContentEncoding, EncodingGzip)
|
|
cw := &compressWriter{ResponseWriter: c.Writer, encoder: gz, encoding: EncodingGzip}
|
|
c.Writer = cw
|
|
defer func() {
|
|
// Nothing encoded — drop Content-Encoding and discard the trailer.
|
|
if !cw.encoded {
|
|
cw.ResponseWriter.Header().Del(header.ContentEncoding)
|
|
gz.Reset(io.Discard)
|
|
}
|
|
_ = gz.Close()
|
|
gzipPool.Put(gz)
|
|
}()
|
|
case EncodingZstd:
|
|
zw := zstdPool.Get().(*zstd.Encoder)
|
|
zw.Reset(c.Writer)
|
|
c.Header(header.ContentEncoding, EncodingZstd)
|
|
cw := &compressWriter{ResponseWriter: c.Writer, encoder: zw, encoding: EncodingZstd}
|
|
c.Writer = cw
|
|
defer func() {
|
|
if !cw.encoded {
|
|
cw.ResponseWriter.Header().Del(header.ContentEncoding)
|
|
zw.Reset(io.Discard)
|
|
}
|
|
_ = zw.Close()
|
|
zstdPool.Put(zw)
|
|
}()
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|