330 lines
9.9 KiB
Go
330 lines
9.9 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package compserve
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"tailscale.com/util/httpm"
|
|
)
|
|
|
|
// CompressWriter wraps w so that its response body is transparently
|
|
// compressed, incrementally as the handler writes it, with the first
|
|
// encoding from encodings (default: [DefaultEncodings]) that offers live
|
|
// compression and that r accepts.
|
|
//
|
|
// The caller must call Close exactly once after the wrapped handler
|
|
// returns, to finish any compressed stream:
|
|
//
|
|
// cw := compserve.CompressWriter(w, r)
|
|
// defer cw.Close()
|
|
// handler.ServeHTTP(cw, r)
|
|
func CompressWriter(w http.ResponseWriter, r *http.Request, encodings ...Encoding) *compressWriter {
|
|
if len(encodings) == 0 {
|
|
encodings = DefaultEncodings
|
|
}
|
|
var encs []Encoding
|
|
for _, e := range encodings {
|
|
if e.Compress != nil {
|
|
encs = append(encs, e)
|
|
}
|
|
}
|
|
// Compression applies to plain responses of standard body-carrying
|
|
// requests: not HEAD (no body), not CONNECT (tunnel), not protocol
|
|
// upgrades, not range requests (range semantics apply to the
|
|
// uncompressed representation).
|
|
safe := (r.Method == httpm.GET || r.Method == httpm.POST) &&
|
|
r.Header.Get("Upgrade") == "" &&
|
|
r.Header.Get("Range") == ""
|
|
return &compressWriter{
|
|
ResponseWriter: w,
|
|
encs: encs,
|
|
ae: r.Header.Get("Accept-Encoding"),
|
|
safe: safe,
|
|
}
|
|
}
|
|
|
|
// compressWriter is a ResponseWriter wrapper that compresses the response
|
|
// body of dynamic handlers. See [CompressWriter].
|
|
//
|
|
// The body is compressed as it is written, with no buffering. The compress
|
|
// decision is made once from the response headers, sniffing the first
|
|
// write if the handler sets no Content-Type.
|
|
//
|
|
// Compressed responses are chunked: the compressed size is not known up
|
|
// front, so a handler-set Content-Length is removed. zstd framing may add
|
|
// a few bytes to incompressible data.
|
|
//
|
|
// Responses pass through uncompressed when they already have a
|
|
// Content-Encoding, on non-GET/POST requests (HEAD, CONNECT, upgrades,
|
|
// range requests), non-200 statuses, or incompressible content types (see
|
|
// compressibleContentType).
|
|
//
|
|
// Every response gets Vary: Accept-Encoding. Strong ETags are weakened:
|
|
// validators are representation-specific.
|
|
type compressWriter struct {
|
|
http.ResponseWriter
|
|
encs []Encoding
|
|
|
|
ae string // Accept-Encoding header of the request
|
|
safe bool // request is safe to compress the response of
|
|
|
|
code int // stashed status code; 0 until the first WriteHeader or Write
|
|
decided bool // whether the compress-or-passthrough decision was made
|
|
compress bool // decision outcome: stream through the compressor
|
|
hijacked bool // connection hijacked; the wrapper is out of the path
|
|
cw io.WriteCloser
|
|
}
|
|
|
|
// Unwrap exposes the wrapped ResponseWriter to http.ResponseController for
|
|
// deadline support; Flush and Hijack stay handled by the wrapper.
|
|
func (c *compressWriter) Unwrap() http.ResponseWriter {
|
|
return c.ResponseWriter
|
|
}
|
|
|
|
// Hijack switches the connection out from under the wrapper: after a
|
|
// successful hijack the connection is raw, no compression applies, and
|
|
// wrapper writes fail. Handlers reach it via http.NewResponseController or
|
|
// a direct type assertion, so hijacked paths carry no io overhead from
|
|
// this wrapper. ResponseController.Hijack unwraps through further
|
|
// middleware below this wrapper.
|
|
func (c *compressWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if !c.decided {
|
|
// No response was produced; the handler will speak for itself on
|
|
// the hijacked connection. Do not attach a compressor or send
|
|
// response headers.
|
|
c.decided = true
|
|
} else if c.compress {
|
|
// Compressed bytes were already streamed; finish the frame and
|
|
// return the encoder to its pool before the connection leaves our
|
|
// control.
|
|
err := c.cw.Close()
|
|
c.compress, c.cw = false, nil
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
c.hijacked = true
|
|
return http.NewResponseController(c.ResponseWriter).Hijack()
|
|
}
|
|
|
|
// WriteHeader stashes the status code, deciding early if the response is
|
|
// already disqualified from compression. See [CompressWriter].
|
|
func (c *compressWriter) WriteHeader(code int) {
|
|
if c.hijacked {
|
|
return // the handler owns the connection
|
|
}
|
|
if c.code != 0 {
|
|
return // ignore superfluous calls, like net/http does
|
|
}
|
|
c.code = code
|
|
if !c.mayCompress() {
|
|
c.decide(nil)
|
|
}
|
|
}
|
|
|
|
// mayCompress reports whether the stashed response could still be
|
|
// compressed based on the request and the handler's headers alone.
|
|
func (c *compressWriter) mayCompress() bool {
|
|
if len(c.encs) == 0 || c.code != http.StatusOK || !c.safe {
|
|
return false
|
|
}
|
|
// Never double-encode (e.g. precompressed variants served by
|
|
// [ServeFile] through this wrapper).
|
|
return c.Header().Get("Content-Encoding") == ""
|
|
}
|
|
|
|
// Write passes the body through the streaming compressor or the wrapped
|
|
// writer, deciding on the first write. See [CompressWriter].
|
|
func (c *compressWriter) Write(p []byte) (int, error) {
|
|
if c.hijacked {
|
|
return 0, http.ErrHijacked
|
|
}
|
|
if !c.decided {
|
|
c.decide(p)
|
|
}
|
|
if c.compress {
|
|
return c.cw.Write(p)
|
|
}
|
|
return c.ResponseWriter.Write(p)
|
|
}
|
|
|
|
// Flush flushes buffered compressed data and the underlying writer. See
|
|
// [CompressWriter].
|
|
func (c *compressWriter) Flush() {
|
|
if !c.decided {
|
|
c.decide(nil)
|
|
}
|
|
if c.compress {
|
|
type flusher interface{ Flush() }
|
|
if f, ok := c.cw.(flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
if f, ok := c.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Close finishes any compressed stream and must be called exactly once
|
|
// after the wrapped handler returns. See [CompressWriter].
|
|
func (c *compressWriter) Close() error {
|
|
if c.hijacked {
|
|
return nil // the connection left our control in Hijack
|
|
}
|
|
if !c.decided {
|
|
c.decide(nil)
|
|
}
|
|
if c.compress {
|
|
return c.cw.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decide makes the compress-or-passthrough decision from the request, the
|
|
// response headers, and (when no Content-Type is set) the first body
|
|
// bytes. It forwards the response headers.
|
|
func (c *compressWriter) decide(first []byte) {
|
|
c.decided = true
|
|
if c.code == 0 {
|
|
c.code = http.StatusOK
|
|
}
|
|
ct := c.Header().Get("Content-Type")
|
|
sniffed := false
|
|
if ct == "" && first != nil {
|
|
// Mirror net/http's sniffing of the first bytes.
|
|
ct = http.DetectContentType(first)
|
|
sniffed = true
|
|
}
|
|
if !c.mayCompress() || ct == "" || !compressibleContentType(ct) {
|
|
c.commitPassthrough()
|
|
return
|
|
}
|
|
enc := negotiate(c.ae, c.encs)
|
|
if enc == nil {
|
|
c.commitPassthrough()
|
|
return
|
|
}
|
|
cw, err := enc.Compress(c.ResponseWriter)
|
|
if err != nil {
|
|
c.commitPassthrough()
|
|
return
|
|
}
|
|
|
|
h := c.Header()
|
|
if sniffed {
|
|
// net/http would otherwise sniff the compressed bytes and label
|
|
// the response application/octet-stream.
|
|
h.Set("Content-Type", ct)
|
|
}
|
|
// The handler's Content-Length describes the uncompressed
|
|
// representation; the compressed length is unknown, so the response is
|
|
// chunked.
|
|
h.Del("Content-Length")
|
|
h.Set("Content-Encoding", enc.Token)
|
|
// Strong validators must be representation-specific.
|
|
if etag := h.Get("ETag"); etag != "" && !strings.HasPrefix(etag, "W/") {
|
|
h.Set("ETag", "W/"+etag)
|
|
}
|
|
addVary(h, "Accept-Encoding")
|
|
|
|
c.cw = cw
|
|
c.compress = true
|
|
c.ResponseWriter.WriteHeader(c.code)
|
|
}
|
|
|
|
// commitPassthrough forwards the stashed status to the wrapped writer;
|
|
// subsequent writes stream through directly.
|
|
func (c *compressWriter) commitPassthrough() {
|
|
if c.code == 0 {
|
|
c.code = http.StatusOK
|
|
}
|
|
addVary(c.Header(), "Accept-Encoding")
|
|
c.ResponseWriter.WriteHeader(c.code)
|
|
}
|
|
|
|
// compressibleContentType reports whether the given content type is worth
|
|
// live compression. Most types are assumed compressible: only content that
|
|
// is already compressed by construction is excluded (media codecs, archive
|
|
// formats), except raw media types that are not, listed below.
|
|
// +json/+xml suffix types are always compressed.
|
|
func compressibleContentType(ct string) bool {
|
|
ct = strings.ToLower(ct)
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = strings.TrimSpace(ct[:i])
|
|
}
|
|
if strings.HasSuffix(ct, "+json") || strings.HasSuffix(ct, "+xml") {
|
|
return true
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(ct, "audio/"):
|
|
return rawAudioContentType(ct)
|
|
case strings.HasPrefix(ct, "video/"):
|
|
return rawVideoContentType(ct)
|
|
}
|
|
switch ct {
|
|
case "application/zip",
|
|
"application/gzip",
|
|
"application/x-gzip",
|
|
"application/x-bzip2",
|
|
"application/x-xz",
|
|
"application/zstd",
|
|
"application/x-zstd",
|
|
"application/x-7z-compressed",
|
|
"application/x-rar-compressed",
|
|
"application/vnd.rar",
|
|
"application/x-compress",
|
|
"application/x-snappy-framed",
|
|
"application/x-lz4",
|
|
// Archive formats that are zip or other compressed containers.
|
|
"application/java-archive", // jar: zip
|
|
"application/vnd.android.package-archive", // apk: zip
|
|
"application/x-apple-diskimage": // dmg: compressed
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// rawAudioContentType reports whether ct is an uncompressed audio format
|
|
// worth compressing despite audio/ being excluded by default: audio content
|
|
// is almost always compressed by its codec, but raw PCM is not.
|
|
func rawAudioContentType(ct string) bool {
|
|
switch ct {
|
|
case "audio/wav", "audio/wave", "audio/x-wav", "audio/vnd.wave", // WAV: PCM
|
|
"audio/pcm",
|
|
"audio/l16", "audio/l24": // raw linear PCM, RFC 2586
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rawVideoContentType reports whether ct is an uncompressed video format
|
|
// worth compressing despite video/ being excluded by default: video content
|
|
// is almost always compressed by its codec, but raw frame data is not.
|
|
func rawVideoContentType(ct string) bool {
|
|
switch ct {
|
|
case "video/raw", "video/x-raw", "video/x-raw-yuv",
|
|
"video/x-y4m": // YUV4MPEG2
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addVary adds token to the response's Vary header, preserving any values
|
|
// already set by the handler.
|
|
func addVary(h http.Header, token string) {
|
|
for _, vary := range h.Values("Vary") {
|
|
for _, t := range strings.Split(vary, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(t), token) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
h.Add("Vary", token)
|
|
}
|