builder.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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. "compress/flate"
  6. "compress/gzip"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "maps"
  11. "mime"
  12. "net/http"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/andybalholm/brotli"
  17. "github.com/andybalholm/brotli/matchfinder"
  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. // Builder generates HTTP responses.
  43. type Builder struct {
  44. w http.ResponseWriter
  45. r *http.Request
  46. statusCode int
  47. headers http.Header
  48. enableCompression bool
  49. body any
  50. }
  51. // NewBuilder creates a new response builder.
  52. func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
  53. return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
  54. }
  55. // WithStatus uses the given status code to build the response.
  56. func (b *Builder) WithStatus(statusCode int) *Builder {
  57. b.statusCode = statusCode
  58. return b
  59. }
  60. // WithHeader adds the given HTTP header to the response.
  61. func (b *Builder) WithHeader(key, value string) *Builder {
  62. b.headers.Set(key, value)
  63. return b
  64. }
  65. // WithBodyAsBytes uses the given bytes to build the response.
  66. func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
  67. b.body = body
  68. return b
  69. }
  70. // WithBodyAsString uses the given string to build the response.
  71. func (b *Builder) WithBodyAsString(body string) *Builder {
  72. b.body = body
  73. return b
  74. }
  75. // WithBodyAsReader uses the given reader to build the response.
  76. func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
  77. b.body = body
  78. return b
  79. }
  80. // WithAttachment forces the document to be downloaded by the web browser.
  81. func (b *Builder) WithAttachment(filename string) *Builder {
  82. b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
  83. return b
  84. }
  85. // WithInline suggests an inline filename for the current response.
  86. func (b *Builder) WithInline(filename string) *Builder {
  87. b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
  88. return b
  89. }
  90. // WithoutCompression disables HTTP compression.
  91. func (b *Builder) WithoutCompression() *Builder {
  92. b.enableCompression = false
  93. return b
  94. }
  95. // WithCaching adds caching headers to the response.
  96. func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
  97. etag = normalizeETag(etag)
  98. b.headers.Set("ETag", etag)
  99. // max-age is required for the "immutable" directive to take effect: without
  100. // it, browsers still revalidate content-hashed assets on every reload.
  101. b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
  102. b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
  103. if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
  104. b.statusCode = http.StatusNotModified
  105. b.body = nil
  106. b.Write()
  107. } else {
  108. callback(b)
  109. }
  110. }
  111. // Write generates the HTTP response.
  112. func (b *Builder) Write() {
  113. if b.body == nil {
  114. b.writeHeaders()
  115. return
  116. }
  117. switch v := b.body.(type) {
  118. case []byte:
  119. b.compress(v)
  120. case string:
  121. b.compress([]byte(v))
  122. case io.Reader:
  123. // Compression not implemented in this case
  124. b.writeHeaders()
  125. _, err := io.Copy(b.w, v)
  126. if err != nil {
  127. slog.Error("Unable to write response body", slog.Any("error", err))
  128. }
  129. }
  130. }
  131. func (b *Builder) writeHeaders() {
  132. b.headers.Set("X-Content-Type-Options", "nosniff")
  133. b.headers.Set("X-Frame-Options", "DENY")
  134. b.headers.Set("Referrer-Policy", "no-referrer")
  135. maps.Copy(b.w.Header(), b.headers)
  136. b.w.WriteHeader(b.statusCode)
  137. }
  138. // values should be in sync with [Builder.compress] switch/case.
  139. var acceptEncoding = AcceptEncoding("br", "gzip", "deflate")
  140. func (b *Builder) compress(data []byte) {
  141. if b.enableCompression && len(data) > compressionThreshold {
  142. b.headers.Set("Vary", "Accept-Encoding")
  143. encoding := acceptEncoding.Parse(b.r.Header.Get("Accept-Encoding"))
  144. switch encoding {
  145. case "br":
  146. b.headers.Set("Content-Encoding", "br")
  147. b.writeHeaders()
  148. brotliWriter := brotliWriterPool.Get().(*matchfinder.Writer)
  149. brotliWriter.Reset(b.w)
  150. brotliWriter.Write(data)
  151. brotliWriter.Close()
  152. brotliWriter.Reset(io.Discard)
  153. brotliWriterPool.Put(brotliWriter)
  154. return
  155. case "gzip":
  156. b.headers.Set("Content-Encoding", "gzip")
  157. b.writeHeaders()
  158. gzipWriter := gzipWriterPool.Get().(*gzip.Writer)
  159. gzipWriter.Reset(b.w)
  160. gzipWriter.Write(data)
  161. gzipWriter.Close()
  162. gzipWriter.Reset(io.Discard)
  163. gzipWriterPool.Put(gzipWriter)
  164. return
  165. case "deflate":
  166. b.headers.Set("Content-Encoding", "deflate")
  167. b.writeHeaders()
  168. flateWriter := flateWriterPool.Get().(*flate.Writer)
  169. flateWriter.Reset(b.w)
  170. flateWriter.Write(data)
  171. flateWriter.Close()
  172. flateWriter.Reset(io.Discard)
  173. flateWriterPool.Put(flateWriter)
  174. return
  175. }
  176. }
  177. b.writeHeaders()
  178. b.w.Write(data)
  179. }
  180. func normalizeETag(etag string) string {
  181. etag = strings.TrimSpace(etag)
  182. if etag == "" {
  183. return ""
  184. }
  185. if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
  186. return etag
  187. }
  188. return `"` + etag + `"`
  189. }
  190. func ifNoneMatch(headerValue, etag string) bool {
  191. if headerValue == "" || etag == "" {
  192. return false
  193. }
  194. if strings.TrimSpace(headerValue) == "*" {
  195. return true
  196. }
  197. // Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
  198. return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
  199. }
  200. func formatContentDisposition(dispositionType, filename string) string {
  201. if filename == "" {
  202. return dispositionType
  203. }
  204. if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
  205. return value
  206. }
  207. return dispositionType
  208. }