4
0

builder.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. "io"
  8. "log/slog"
  9. "maps"
  10. "mime"
  11. "net/http"
  12. "strings"
  13. "time"
  14. "github.com/andybalholm/brotli"
  15. )
  16. const compressionThreshold = 1024
  17. // Builder generates HTTP responses.
  18. type Builder struct {
  19. w http.ResponseWriter
  20. r *http.Request
  21. statusCode int
  22. headers http.Header
  23. enableCompression bool
  24. body any
  25. }
  26. // NewBuilder creates a new response builder.
  27. func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
  28. return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
  29. }
  30. // WithStatus uses the given status code to build the response.
  31. func (b *Builder) WithStatus(statusCode int) *Builder {
  32. b.statusCode = statusCode
  33. return b
  34. }
  35. // WithHeader adds the given HTTP header to the response.
  36. func (b *Builder) WithHeader(key, value string) *Builder {
  37. b.headers.Set(key, value)
  38. return b
  39. }
  40. // WithBodyAsBytes uses the given bytes to build the response.
  41. func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
  42. b.body = body
  43. return b
  44. }
  45. // WithBodyAsString uses the given string to build the response.
  46. func (b *Builder) WithBodyAsString(body string) *Builder {
  47. b.body = body
  48. return b
  49. }
  50. // WithBodyAsReader uses the given reader to build the response.
  51. func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
  52. b.body = body
  53. return b
  54. }
  55. // WithAttachment forces the document to be downloaded by the web browser.
  56. func (b *Builder) WithAttachment(filename string) *Builder {
  57. b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
  58. return b
  59. }
  60. // WithInline suggests an inline filename for the current response.
  61. func (b *Builder) WithInline(filename string) *Builder {
  62. b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
  63. return b
  64. }
  65. // WithoutCompression disables HTTP compression.
  66. func (b *Builder) WithoutCompression() *Builder {
  67. b.enableCompression = false
  68. return b
  69. }
  70. // WithCaching adds caching headers to the response.
  71. func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
  72. etag = normalizeETag(etag)
  73. b.headers.Set("ETag", etag)
  74. b.headers.Set("Cache-Control", "public, immutable")
  75. b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
  76. if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
  77. b.statusCode = http.StatusNotModified
  78. b.body = nil
  79. b.Write()
  80. } else {
  81. callback(b)
  82. }
  83. }
  84. // Write generates the HTTP response.
  85. func (b *Builder) Write() {
  86. if b.body == nil {
  87. b.writeHeaders()
  88. return
  89. }
  90. switch v := b.body.(type) {
  91. case []byte:
  92. b.compress(v)
  93. case string:
  94. b.compress([]byte(v))
  95. case io.Reader:
  96. // Compression not implemented in this case
  97. b.writeHeaders()
  98. _, err := io.Copy(b.w, v)
  99. if err != nil {
  100. slog.Error("Unable to write response body", slog.Any("error", err))
  101. }
  102. }
  103. }
  104. func (b *Builder) writeHeaders() {
  105. b.headers.Set("X-Content-Type-Options", "nosniff")
  106. b.headers.Set("X-Frame-Options", "DENY")
  107. b.headers.Set("Referrer-Policy", "no-referrer")
  108. maps.Copy(b.w.Header(), b.headers)
  109. b.w.WriteHeader(b.statusCode)
  110. }
  111. func (b *Builder) compress(data []byte) {
  112. if b.enableCompression && len(data) > compressionThreshold {
  113. b.headers.Set("Vary", "Accept-Encoding")
  114. acceptEncoding := b.r.Header.Get("Accept-Encoding")
  115. switch {
  116. case strings.Contains(acceptEncoding, "br"):
  117. b.headers.Set("Content-Encoding", "br")
  118. b.writeHeaders()
  119. brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
  120. brotliWriter.Write(data)
  121. brotliWriter.Close()
  122. return
  123. case strings.Contains(acceptEncoding, "gzip"):
  124. b.headers.Set("Content-Encoding", "gzip")
  125. b.writeHeaders()
  126. gzipWriter := gzip.NewWriter(b.w)
  127. gzipWriter.Write(data)
  128. gzipWriter.Close()
  129. return
  130. case strings.Contains(acceptEncoding, "deflate"):
  131. b.headers.Set("Content-Encoding", "deflate")
  132. b.writeHeaders()
  133. flateWriter, _ := flate.NewWriter(b.w, -1)
  134. flateWriter.Write(data)
  135. flateWriter.Close()
  136. return
  137. }
  138. }
  139. b.writeHeaders()
  140. b.w.Write(data)
  141. }
  142. func normalizeETag(etag string) string {
  143. etag = strings.TrimSpace(etag)
  144. if etag == "" {
  145. return ""
  146. }
  147. if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
  148. return etag
  149. }
  150. return `"` + etag + `"`
  151. }
  152. func ifNoneMatch(headerValue, etag string) bool {
  153. if headerValue == "" || etag == "" {
  154. return false
  155. }
  156. if strings.TrimSpace(headerValue) == "*" {
  157. return true
  158. }
  159. // Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
  160. return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
  161. }
  162. func formatContentDisposition(dispositionType, filename string) string {
  163. if filename == "" {
  164. return dispositionType
  165. }
  166. if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
  167. return value
  168. }
  169. return dispositionType
  170. }