1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.ReverseProxy do
6 @range_headers ~w(range if-range)
7 @keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++
8 ~w(if-unmodified-since if-none-match) ++ @range_headers
9 @resp_cache_headers ~w(etag date last-modified)
10 @keep_resp_headers @resp_cache_headers ++
11 ~w(content-length content-type content-disposition content-encoding) ++
12 ~w(content-range accept-ranges vary expires)
13 @default_cache_control_header "public, max-age=1209600"
14 @valid_resp_codes [200, 206, 304]
15 @max_read_duration :timer.seconds(30)
16 @max_body_length :infinity
17 @failed_request_ttl :timer.seconds(60)
20 @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
22 def max_read_duration_default, do: @max_read_duration
23 def default_cache_control_header, do: @default_cache_control_header
28 Pleroma.ReverseProxy.call(conn, url, options)
30 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
32 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
34 Responses are chunked to the client while downloading from the upstream.
36 Some request / responses headers are preserved:
38 * request: `#{inspect(@keep_req_headers)}`
39 * response: `#{inspect(@keep_resp_headers)}`
43 * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
44 errors. Any error during body processing will not be redirected as the response is chunked. This may expose
45 remote URL, clients IPs, ….
47 * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
48 specified length. It is validated with the `content-length` header and also verified when proxying.
50 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
51 read from the remote upstream.
53 * `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried.
55 * `inline_content_types`:
56 * `true` will not alter `content-disposition` (up to the upstream),
57 * `false` will add `content-disposition: attachment` to any request,
58 * a list of whitelisted content types
60 * `req_headers`, `resp_headers` additional headers.
63 @inline_content_types [
80 {:max_read_duration, :timer.time() | :infinity}
81 | {:max_body_length, non_neg_integer() | :infinity}
82 | {:failed_request_ttl, :timer.time() | :infinity}
84 | {:req_headers, [{String.t(), String.t()}]}
85 | {:resp_headers, [{String.t(), String.t()}]}
86 | {:inline_content_types, boolean() | [String.t()]}
87 | {:redirect_on_failure, boolean()}
89 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
90 def call(_conn, _url, _opts \\ [])
92 def call(conn = %{method: method}, url, opts) when method in @methods do
93 client_opts = Keyword.get(opts, :http, [])
95 req_headers = build_req_headers(conn.req_headers, opts)
98 if filename = Pleroma.Web.MediaProxy.filename(url) do
99 Keyword.put_new(opts, :attachment_name, filename)
104 with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
105 {:ok, status, headers, body} <- request(method, url, req_headers, client_opts),
107 header_length_constraint(
109 Keyword.get(opts, :max_body_length, @max_body_length)
112 |> put_private(:proxied_url, url)
113 |> response(body, status, headers, opts)
117 |> error_or_redirect(500, "Request failed", opts)
120 {:ok, status, headers} ->
122 |> put_private(:proxied_url, url)
123 |> head_response(status, headers, opts)
126 {:error, {:invalid_http_response, status}} ->
128 "#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{status}"
131 track_failed_url(url, status, opts)
134 |> put_private(:proxied_url, url)
135 |> error_or_redirect(
137 "Request failed: " <> Plug.Conn.Status.reason_phrase(status),
143 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
144 track_failed_url(url, error, opts)
147 |> put_private(:proxied_url, url)
148 |> error_or_redirect(500, "Request failed", opts)
153 def call(conn, _, _) do
155 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
159 defp request(method, url, headers, opts) do
160 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
161 method = method |> String.downcase() |> String.to_existing_atom()
163 opts = opts ++ [receive_timeout: @max_read_duration]
165 case Pleroma.HTTP.request(method, url, "", headers, opts) do
166 {:ok, %Tesla.Env{status: status, headers: headers, body: body}}
167 when status in @valid_resp_codes ->
168 {:ok, status, downcase_headers(headers), body}
170 {:ok, %Tesla.Env{status: status, headers: headers}} when status in @valid_resp_codes ->
171 {:ok, status, downcase_headers(headers)}
173 {:ok, %Tesla.Env{status: status}} ->
174 {:error, {:invalid_http_response, status}}
181 defp response(conn, body, status, headers, opts) do
182 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
185 |> put_resp_headers(build_resp_headers(headers, opts))
186 |> send_resp(status, body)
189 defp head_response(conn, status, headers, opts) do
190 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
193 |> put_resp_headers(build_resp_headers(headers, opts))
194 |> send_resp(status, "")
197 defp error_or_redirect(conn, status, body, opts) do
198 if Keyword.get(opts, :redirect_on_failure, false) do
200 |> Phoenix.Controller.redirect(external: conn.private[:proxied_url])
204 |> send_resp(status, body)
209 defp downcase_headers(headers) do
210 Enum.map(headers, fn {k, v} ->
211 {String.downcase(k), v}
215 defp get_content_type(headers) do
217 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
219 [content_type | _] = String.split(content_type, ";")
223 defp put_resp_headers(conn, headers) do
224 Enum.reduce(headers, conn, fn {k, v}, conn ->
225 put_resp_header(conn, k, v)
229 defp build_req_headers(headers, opts) do
231 |> downcase_headers()
232 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
233 |> build_req_range_or_encoding_header(opts)
234 |> Keyword.merge(Keyword.get(opts, :req_headers, []))
237 # Disable content-encoding if any @range_headers are requested (see #1823).
238 defp build_req_range_or_encoding_header(headers, _opts) do
239 range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
241 if range? && List.keymember?(headers, "accept-encoding", 0) do
242 List.keydelete(headers, "accept-encoding", 0)
248 defp build_resp_headers(headers, opts) do
250 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
251 |> build_resp_cache_headers(opts)
252 |> build_resp_content_disposition_header(opts)
253 |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
256 defp build_resp_cache_headers(headers, _opts) do
257 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
261 # There's caching header present but no cache-control -- we need to set our own
262 # as Plug defaults to "max-age=0, private, must-revalidate"
267 {"cache-control", @default_cache_control_header}
275 {"cache-control", @default_cache_control_header}
280 defp build_resp_content_disposition_header(headers, opts) do
281 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
283 content_type = get_content_type(headers)
287 is_list(opt) && !Enum.member?(opt, content_type) -> true
295 {{"content-disposition", content_disposition_string}, _} =
296 List.keytake(headers, "content-disposition", 0)
300 ~r/filename="((?:[^"\\]|\\.)*)"/u,
301 content_disposition_string || "",
302 capture: :all_but_first
307 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
310 disposition = "attachment; filename=\"#{name}\""
312 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
318 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
319 with {_, size} <- List.keyfind(headers, "content-length", 0),
320 {size, _} <- Integer.parse(size),
321 true <- size <= limit do
325 {:error, :body_too_large}
332 defp header_length_constraint(_, _), do: :ok
334 defp track_failed_url(url, error, opts) do
336 unless error in [:body_too_large, 400, 204] do
337 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
342 @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)