client.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package client // import "miniflux.app/v2/internal/http/client"
  4. import (
  5. "bytes"
  6. "crypto/tls"
  7. "crypto/x509"
  8. "fmt"
  9. "io"
  10. "log/slog"
  11. "net"
  12. "net/http"
  13. "net/url"
  14. "time"
  15. "miniflux.app/v2/internal/config"
  16. "miniflux.app/v2/internal/errors"
  17. )
  18. const (
  19. defaultHTTPClientTimeout = 20
  20. defaultHTTPClientMaxBodySize = 15 * 1024 * 1024
  21. )
  22. var (
  23. errInvalidCertificate = "Invalid SSL certificate (original error: %q)"
  24. errNetworkOperation = "This website is unreachable (original error: %q)"
  25. errRequestTimeout = "Website unreachable, the request timed out after %d seconds"
  26. )
  27. // Client builds and executes HTTP requests.
  28. type Client struct {
  29. inputURL string
  30. requestEtagHeader string
  31. requestLastModifiedHeader string
  32. requestAuthorizationHeader string
  33. requestUsername string
  34. requestPassword string
  35. requestUserAgent string
  36. requestCookie string
  37. customHeaders map[string]string
  38. useProxy bool
  39. doNotFollowRedirects bool
  40. ClientTimeout int
  41. ClientMaxBodySize int64
  42. ClientProxyURL string
  43. AllowSelfSignedCertificates bool
  44. }
  45. // New initializes a new HTTP client.
  46. func New(url string) *Client {
  47. return &Client{
  48. inputURL: url,
  49. ClientTimeout: defaultHTTPClientTimeout,
  50. ClientMaxBodySize: defaultHTTPClientMaxBodySize,
  51. }
  52. }
  53. // NewClientWithConfig initializes a new HTTP client with application config options.
  54. func NewClientWithConfig(url string, opts *config.Options) *Client {
  55. return &Client{
  56. inputURL: url,
  57. requestUserAgent: opts.HTTPClientUserAgent(),
  58. ClientTimeout: opts.HTTPClientTimeout(),
  59. ClientMaxBodySize: opts.HTTPClientMaxBodySize(),
  60. ClientProxyURL: opts.HTTPClientProxy(),
  61. }
  62. }
  63. // WithCredentials defines the username/password for HTTP Basic authentication.
  64. func (c *Client) WithCredentials(username, password string) *Client {
  65. if username != "" && password != "" {
  66. c.requestUsername = username
  67. c.requestPassword = password
  68. }
  69. return c
  70. }
  71. // WithCustomHeaders defines custom HTTP headers.
  72. func (c *Client) WithCustomHeaders(customHeaders map[string]string) *Client {
  73. c.customHeaders = customHeaders
  74. return c
  75. }
  76. // WithCacheHeaders defines caching headers.
  77. func (c *Client) WithCacheHeaders(etagHeader, lastModifiedHeader string) *Client {
  78. c.requestEtagHeader = etagHeader
  79. c.requestLastModifiedHeader = lastModifiedHeader
  80. return c
  81. }
  82. // WithProxy enables proxy for the current HTTP request.
  83. func (c *Client) WithProxy() *Client {
  84. c.useProxy = true
  85. return c
  86. }
  87. // WithoutRedirects disables HTTP redirects.
  88. func (c *Client) WithoutRedirects() *Client {
  89. c.doNotFollowRedirects = true
  90. return c
  91. }
  92. // WithUserAgent defines the User-Agent header to use for HTTP requests.
  93. func (c *Client) WithUserAgent(userAgent string) *Client {
  94. if userAgent != "" {
  95. c.requestUserAgent = userAgent
  96. }
  97. return c
  98. }
  99. // WithCookie defines the Cookies to use for HTTP requests.
  100. func (c *Client) WithCookie(cookie string) *Client {
  101. if cookie != "" {
  102. c.requestCookie = cookie
  103. }
  104. return c
  105. }
  106. // Get performs a GET HTTP request.
  107. func (c *Client) Get() (*Response, error) {
  108. request, err := c.buildRequest(http.MethodGet, nil)
  109. if err != nil {
  110. return nil, err
  111. }
  112. return c.executeRequest(request)
  113. }
  114. func (c *Client) executeRequest(request *http.Request) (*Response, error) {
  115. startTime := time.Now()
  116. slog.Debug("Executing outgoing HTTP request",
  117. slog.Group("request",
  118. slog.String("method", request.Method),
  119. slog.String("url", request.URL.String()),
  120. slog.String("user_agent", request.UserAgent()),
  121. slog.Bool("is_authenticated", c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != "")),
  122. slog.Bool("has_cookie", c.requestCookie != ""),
  123. slog.Bool("with_redirects", !c.doNotFollowRedirects),
  124. slog.Bool("with_proxy", c.useProxy),
  125. slog.String("proxy_url", c.ClientProxyURL),
  126. slog.Bool("with_caching_headers", c.requestEtagHeader != "" || c.requestLastModifiedHeader != ""),
  127. ),
  128. )
  129. client := c.buildClient()
  130. resp, err := client.Do(request)
  131. if resp != nil {
  132. defer resp.Body.Close()
  133. }
  134. if err != nil {
  135. if uerr, ok := err.(*url.Error); ok {
  136. switch uerr.Err.(type) {
  137. case x509.CertificateInvalidError, x509.HostnameError:
  138. err = errors.NewLocalizedError(errInvalidCertificate, uerr.Err)
  139. case *net.OpError:
  140. err = errors.NewLocalizedError(errNetworkOperation, uerr.Err)
  141. case net.Error:
  142. nerr := uerr.Err.(net.Error)
  143. if nerr.Timeout() {
  144. err = errors.NewLocalizedError(errRequestTimeout, c.ClientTimeout)
  145. }
  146. }
  147. }
  148. return nil, err
  149. }
  150. if resp.ContentLength > c.ClientMaxBodySize {
  151. return nil, fmt.Errorf("client: response too large (%d bytes)", resp.ContentLength)
  152. }
  153. buf, err := io.ReadAll(resp.Body)
  154. if err != nil {
  155. return nil, fmt.Errorf("client: error while reading body %v", err)
  156. }
  157. response := &Response{
  158. Body: bytes.NewReader(buf),
  159. StatusCode: resp.StatusCode,
  160. EffectiveURL: resp.Request.URL.String(),
  161. LastModified: resp.Header.Get("Last-Modified"),
  162. ETag: resp.Header.Get("ETag"),
  163. Expires: resp.Header.Get("Expires"),
  164. ContentType: resp.Header.Get("Content-Type"),
  165. ContentLength: resp.ContentLength,
  166. }
  167. slog.Debug("Completed outgoing HTTP request",
  168. slog.Duration("duration", time.Since(startTime)),
  169. slog.Group("request",
  170. slog.String("method", request.Method),
  171. slog.String("url", request.URL.String()),
  172. slog.String("user_agent", request.UserAgent()),
  173. slog.Bool("is_authenticated", c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != "")),
  174. slog.Bool("has_cookie", c.requestCookie != ""),
  175. slog.Bool("with_redirects", !c.doNotFollowRedirects),
  176. slog.Bool("with_proxy", c.useProxy),
  177. slog.String("proxy_url", c.ClientProxyURL),
  178. slog.Bool("with_caching_headers", c.requestEtagHeader != "" || c.requestLastModifiedHeader != ""),
  179. ),
  180. slog.Group("response",
  181. slog.Int("status_code", response.StatusCode),
  182. slog.String("effective_url", response.EffectiveURL),
  183. slog.String("content_type", response.ContentType),
  184. slog.Int64("content_length", response.ContentLength),
  185. slog.String("last_modified", response.LastModified),
  186. slog.String("etag", response.ETag),
  187. slog.String("expires", response.Expires),
  188. ),
  189. )
  190. // Ignore caching headers for feeds that do not want any cache.
  191. if resp.Header.Get("Expires") == "0" {
  192. response.ETag = ""
  193. response.LastModified = ""
  194. }
  195. return response, err
  196. }
  197. func (c *Client) buildRequest(method string, body io.Reader) (*http.Request, error) {
  198. request, err := http.NewRequest(method, c.inputURL, body)
  199. if err != nil {
  200. return nil, err
  201. }
  202. request.Header = c.buildHeaders()
  203. if c.requestUsername != "" && c.requestPassword != "" {
  204. request.SetBasicAuth(c.requestUsername, c.requestPassword)
  205. }
  206. return request, nil
  207. }
  208. func (c *Client) buildClient() http.Client {
  209. client := http.Client{
  210. Timeout: time.Duration(c.ClientTimeout) * time.Second,
  211. }
  212. transport := &http.Transport{
  213. Proxy: http.ProxyFromEnvironment,
  214. DialContext: (&net.Dialer{
  215. // Default is 30s.
  216. Timeout: 10 * time.Second,
  217. // Default is 30s.
  218. KeepAlive: 15 * time.Second,
  219. }).DialContext,
  220. // Default is 100.
  221. MaxIdleConns: 50,
  222. // Default is 90s.
  223. IdleConnTimeout: 10 * time.Second,
  224. }
  225. if c.AllowSelfSignedCertificates {
  226. transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
  227. }
  228. if c.doNotFollowRedirects {
  229. client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  230. return http.ErrUseLastResponse
  231. }
  232. }
  233. if c.useProxy && c.ClientProxyURL != "" {
  234. proxyURL, err := url.Parse(c.ClientProxyURL)
  235. if err != nil {
  236. slog.Error("Unable to parse proxy URL",
  237. slog.String("proxy_url", c.ClientProxyURL),
  238. slog.Any("error", err),
  239. )
  240. } else {
  241. transport.Proxy = http.ProxyURL(proxyURL)
  242. }
  243. }
  244. client.Transport = transport
  245. return client
  246. }
  247. func (c *Client) buildHeaders() http.Header {
  248. headers := make(http.Header)
  249. headers.Add("Accept", "*/*")
  250. if c.requestUserAgent != "" {
  251. headers.Add("User-Agent", c.requestUserAgent)
  252. }
  253. if c.requestEtagHeader != "" {
  254. headers.Add("If-None-Match", c.requestEtagHeader)
  255. }
  256. if c.requestLastModifiedHeader != "" {
  257. headers.Add("If-Modified-Since", c.requestLastModifiedHeader)
  258. }
  259. if c.requestAuthorizationHeader != "" {
  260. headers.Add("Authorization", c.requestAuthorizationHeader)
  261. }
  262. if c.requestCookie != "" {
  263. headers.Add("Cookie", c.requestCookie)
  264. }
  265. for key, value := range c.customHeaders {
  266. headers.Add(key, value)
  267. }
  268. headers.Add("Connection", "close")
  269. return headers
  270. }