builder.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package response // import "miniflux.app/v2/internal/http/response"
  4. import (
  5. "bytes"
  6. "compress/flate"
  7. "compress/gzip"
  8. "fmt"
  9. "io"
  10. "log/slog"
  11. "maps"
  12. "mime"
  13. "net/http"
  14. "strings"
  15. "sync"
  16. "time"
  17. "github.com/andybalholm/brotli"
  18. )
  19. const compressionThreshold = 1024
  20. // Compression writers are pooled so each request reuses their internal
  21. // state (brotli sliding window + hash tables, flate dictionary, etc.)
  22. // instead of allocating it from scratch. Reset(dst) rebinds the
  23. // destination without re-allocating the buffers.
  24. var (
  25. brotliWriterPool = sync.Pool{
  26. New: func() any {
  27. return brotli.NewWriterV2(io.Discard, brotli.DefaultCompression)
  28. },
  29. }
  30. gzipWriterPool = sync.Pool{
  31. New: func() any {
  32. return gzip.NewWriter(io.Discard)
  33. },
  34. }
  35. flateWriterPool = sync.Pool{
  36. New: func() any {
  37. w, _ := flate.NewWriter(io.Discard, flate.DefaultCompression)
  38. return w
  39. },
  40. }
  41. )
  42. type writeResetCloser interface {
  43. io.WriteCloser
  44. Reset(io.Writer)
  45. }
  46. func pooledWriter(pool *sync.Pool, dst io.Writer) (io.WriteCloser, func()) {
  47. w := pool.Get().(writeResetCloser)
  48. w.Reset(dst)
  49. cancel := func() {
  50. w.Reset(io.Discard)
  51. pool.Put(w)
  52. }
  53. return w, cancel
  54. }
  55. // Builder generates HTTP responses.
  56. type Builder struct {
  57. w http.ResponseWriter
  58. r *http.Request
  59. statusCode int
  60. headers http.Header
  61. enableCompression bool
  62. body io.Reader
  63. }
  64. // NewBuilder creates a new response builder.
  65. func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
  66. return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
  67. }
  68. // WithStatus uses the given status code to build the response.
  69. func (b *Builder) WithStatus(statusCode int) *Builder {
  70. b.statusCode = statusCode
  71. return b
  72. }
  73. // WithHeader adds the given HTTP header to the response.
  74. func (b *Builder) WithHeader(key, value string) *Builder {
  75. b.headers.Set(key, value)
  76. return b
  77. }
  78. // WithBodyAsBytes uses the given bytes to build the response.
  79. func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
  80. b.body = bytes.NewReader(body)
  81. return b
  82. }
  83. // WithBodyAsString uses the given string to build the response.
  84. func (b *Builder) WithBodyAsString(body string) *Builder {
  85. b.body = strings.NewReader(body)
  86. return b
  87. }
  88. // WithBodyAsReader uses the given reader to build the response.
  89. func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
  90. b.body = body
  91. return b
  92. }
  93. // WithAttachment forces the document to be downloaded by the web browser.
  94. func (b *Builder) WithAttachment(filename string) *Builder {
  95. b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
  96. return b
  97. }
  98. // WithInline suggests an inline filename for the current response.
  99. func (b *Builder) WithInline(filename string) *Builder {
  100. b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
  101. return b
  102. }
  103. // WithoutCompression disables HTTP compression.
  104. func (b *Builder) WithoutCompression() *Builder {
  105. b.enableCompression = false
  106. return b
  107. }
  108. // WithCaching adds caching headers to the response.
  109. func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
  110. etag = normalizeETag(etag)
  111. b.headers.Set("ETag", etag)
  112. // max-age is required for the "immutable" directive to take effect: without
  113. // it, browsers still revalidate content-hashed assets on every reload.
  114. b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
  115. b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
  116. if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
  117. b.statusCode = http.StatusNotModified
  118. b.body = nil
  119. b.Write()
  120. } else {
  121. callback(b)
  122. }
  123. }
  124. type nopWriteCloser struct {
  125. io.Writer
  126. }
  127. func (nopWriteCloser) Close() error { return nil }
  128. type nopWriteCloserReaderFrom struct {
  129. io.Writer
  130. }
  131. func (nopWriteCloserReaderFrom) Close() error { return nil }
  132. func (c nopWriteCloserReaderFrom) ReadFrom(r io.Reader) (n int64, err error) {
  133. return c.Writer.(io.ReaderFrom).ReadFrom(r)
  134. }
  135. // Write generates the HTTP response.
  136. func (b *Builder) Write() {
  137. if b.body == nil {
  138. b.writeHeaders()
  139. return
  140. }
  141. cancel := func() {}
  142. defer func() { cancel() }()
  143. var wc io.WriteCloser
  144. switch b.acceptEncoding() {
  145. case "":
  146. // A hack to avoid infinite loop as [HTMLNotAcceptable] also calls [NewBuilder] from request.
  147. cloned := b.r.Clone(b.r.Context())
  148. cloned.Header.Del("Content-Encoding")
  149. // [Builder.acceptEncoding] returns "" as per [HTTP Semantics]:
  150. // As long as the identity;q=0 or *;q=0 directives do not explicitly forbid
  151. // the identity value that means no encoding, the server must never return
  152. // a 406 Not Acceptable error.
  153. //
  154. // [HTTP Semantics]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding.
  155. HTMLNotAcceptable(b.w, cloned)
  156. return
  157. case brotliEncoding:
  158. b.headers.Set("Content-Encoding", brotliEncoding)
  159. wc, cancel = pooledWriter(&brotliWriterPool, b.w)
  160. case gzipEncoding:
  161. b.headers.Set("Content-Encoding", gzipEncoding)
  162. wc, cancel = pooledWriter(&gzipWriterPool, b.w)
  163. case deflateEncoding:
  164. b.headers.Set("Content-Encoding", deflateEncoding)
  165. wc, cancel = pooledWriter(&flateWriterPool, b.w)
  166. default:
  167. // Fallback writer with no compression.
  168. // Wraps [io.Writer] to make it implement [io.WriteCloser].
  169. //
  170. // That is straight-up copy from [io.NopCloser] for [io.Reader]
  171. // as [io] doesn't have an alternative for [io.Writer].
  172. wc = nopWriteCloser{b.w}
  173. if _, ok := b.w.(io.ReaderFrom); ok {
  174. wc = nopWriteCloserReaderFrom{b.w}
  175. }
  176. }
  177. b.headers.Set("Vary", "Accept-Encoding")
  178. b.writeHeaders()
  179. _, err := io.Copy(wc, b.body)
  180. if err != nil {
  181. slog.Error("Unable to write response body", "error", err)
  182. }
  183. wc.Close()
  184. }
  185. func (b *Builder) writeHeaders() {
  186. b.headers.Set("X-Content-Type-Options", "nosniff")
  187. b.headers.Set("X-Frame-Options", "DENY")
  188. b.headers.Set("Referrer-Policy", "no-referrer")
  189. maps.Copy(b.w.Header(), b.headers)
  190. b.w.WriteHeader(b.statusCode)
  191. }
  192. const (
  193. brotliEncoding = "br"
  194. gzipEncoding = "gzip"
  195. deflateEncoding = "deflate"
  196. identityEncoding = "identity"
  197. )
  198. var acceptEncoding = AcceptEncoding(
  199. brotliEncoding,
  200. gzipEncoding,
  201. deflateEncoding,
  202. )
  203. // acceptEncoding returns appropriate encoding for the response.
  204. //
  205. // Encoding will always be "identity" when compression explicitly disabled
  206. // or size of the body under [compressionThreshold] bytes.
  207. //
  208. // Otherwise, it is decided by "Accept-Encoding" header.
  209. func (b *Builder) acceptEncoding() string {
  210. type sizer interface {
  211. Size() int64
  212. }
  213. encoding := acceptEncoding.Parse(b.r.Header.Get("Accept-Encoding"))
  214. if encoding == "" || encoding == identityEncoding {
  215. return encoding
  216. }
  217. if !b.enableCompression {
  218. return identityEncoding
  219. }
  220. if body, ok := b.body.(sizer); ok && body.Size() <= compressionThreshold {
  221. return identityEncoding
  222. }
  223. return encoding
  224. }
  225. func normalizeETag(etag string) string {
  226. etag = strings.TrimSpace(etag)
  227. if etag == "" {
  228. return ""
  229. }
  230. if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
  231. return etag
  232. }
  233. return `"` + etag + `"`
  234. }
  235. func ifNoneMatch(headerValue, etag string) bool {
  236. if headerValue == "" || etag == "" {
  237. return false
  238. }
  239. if strings.TrimSpace(headerValue) == "*" {
  240. return true
  241. }
  242. // Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
  243. return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
  244. }
  245. func formatContentDisposition(dispositionType, filename string) string {
  246. if filename == "" {
  247. return dispositionType
  248. }
  249. if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
  250. return value
  251. }
  252. return dispositionType
  253. }