1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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 user-agent 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)
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 * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
61 doing content transformation (encoding, …) depending on the request.
63 * `req_headers`, `resp_headers` additional headers.
65 * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun).
68 @default_options [pool: :media]
70 @inline_content_types [
87 {:keep_user_agent, boolean}
88 | {:max_read_duration, :timer.time() | :infinity}
89 | {:max_body_length, non_neg_integer() | :infinity}
90 | {:failed_request_ttl, :timer.time() | :infinity}
92 | {:req_headers, [{String.t(), String.t()}]}
93 | {:resp_headers, [{String.t(), String.t()}]}
94 | {:inline_content_types, boolean() | [String.t()]}
95 | {:redirect_on_failure, boolean()}
97 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
98 def call(_conn, _url, _opts \\ [])
100 def call(conn = %{method: method}, url, opts) when method in @methods do
101 client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, []))
103 req_headers = build_req_headers(conn.req_headers, opts)
106 if filename = Pleroma.Web.MediaProxy.filename(url) do
107 Keyword.put_new(opts, :attachment_name, filename)
112 with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
113 {:ok, code, headers, client} <- request(method, url, req_headers, client_opts),
115 header_length_constraint(
117 Keyword.get(opts, :max_body_length, @max_body_length)
119 response(conn, client, url, code, headers, opts)
123 |> error_or_redirect(url, 500, "Request failed", opts)
126 {:ok, code, headers} ->
127 head_response(conn, url, code, headers, opts)
130 {:error, {:invalid_http_response, code}} ->
131 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
132 track_failed_url(url, code, opts)
135 |> error_or_redirect(
138 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
144 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
145 track_failed_url(url, error, opts)
148 |> error_or_redirect(url, 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 case client().request(method, url, headers, "", opts) do
164 {:ok, code, headers, client} when code in @valid_resp_codes ->
165 {:ok, code, downcase_headers(headers), client}
167 {:ok, code, headers} when code in @valid_resp_codes ->
168 {:ok, code, downcase_headers(headers)}
171 {:error, {:invalid_http_response, code}}
174 {:error, {:invalid_http_response, code}}
181 defp response(conn, client, url, status, headers, opts) do
182 Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
186 |> put_resp_headers(build_resp_headers(headers, opts))
187 |> send_chunked(status)
188 |> chunk_reply(client, opts)
194 {:error, :closed, conn} ->
195 client().close(client)
198 {:error, error, conn} ->
200 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
203 client().close(client)
208 defp chunk_reply(conn, client, opts) do
209 chunk_reply(conn, client, opts, 0, 0)
212 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
213 with {:ok, duration} <-
216 Keyword.get(opts, :max_read_duration, @max_read_duration)
218 {:ok, data, client} <- client().stream_body(client),
219 {:ok, duration} <- increase_read_duration(duration),
220 sent_so_far = sent_so_far + byte_size(data),
222 body_size_constraint(
224 Keyword.get(opts, :max_body_length, @max_body_length)
226 {:ok, conn} <- chunk(conn, data) do
227 chunk_reply(conn, client, opts, sent_so_far, duration)
230 {:error, error} -> {:error, error, conn}
234 defp head_response(conn, url, code, headers, opts) do
235 Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
238 |> put_resp_headers(build_resp_headers(headers, opts))
239 |> send_resp(code, "")
242 defp error_or_redirect(conn, url, code, body, opts) do
243 if Keyword.get(opts, :redirect_on_failure, false) do
245 |> Phoenix.Controller.redirect(external: url)
249 |> send_resp(code, body)
254 defp downcase_headers(headers) do
255 Enum.map(headers, fn {k, v} ->
256 {String.downcase(k), v}
260 defp get_content_type(headers) do
262 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
264 [content_type | _] = String.split(content_type, ";")
268 defp put_resp_headers(conn, headers) do
269 Enum.reduce(headers, conn, fn {k, v}, conn ->
270 put_resp_header(conn, k, v)
274 defp build_req_headers(headers, opts) do
276 |> downcase_headers()
277 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
278 |> build_req_range_or_encoding_header(opts)
279 |> build_req_user_agent_header(opts)
280 |> Keyword.merge(Keyword.get(opts, :req_headers, []))
283 # Disable content-encoding if any @range_headers are requested (see #1823).
284 defp build_req_range_or_encoding_header(headers, _opts) do
285 range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
287 if range? && List.keymember?(headers, "accept-encoding", 0) do
288 List.keydelete(headers, "accept-encoding", 0)
294 defp build_req_user_agent_header(headers, opts) do
295 if Keyword.get(opts, :keep_user_agent, false) do
300 {"user-agent", Pleroma.Application.user_agent()}
307 defp build_resp_headers(headers, opts) do
309 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
310 |> build_resp_cache_headers(opts)
311 |> build_resp_content_disposition_header(opts)
312 |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
315 defp build_resp_cache_headers(headers, _opts) do
316 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
320 # There's caching header present but no cache-control -- we need to set our own
321 # as Plug defaults to "max-age=0, private, must-revalidate"
326 {"cache-control", @default_cache_control_header}
334 {"cache-control", @default_cache_control_header}
339 defp build_resp_content_disposition_header(headers, opts) do
340 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
342 content_type = get_content_type(headers)
346 is_list(opt) && !Enum.member?(opt, content_type) -> true
354 {{"content-disposition", content_disposition_string}, _} =
355 List.keytake(headers, "content-disposition", 0)
359 ~r/filename="((?:[^"\\]|\\.)*)"/u,
360 content_disposition_string || "",
361 capture: :all_but_first
366 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
369 disposition = "attachment; filename=\"#{name}\""
371 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
377 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
378 with {_, size} <- List.keyfind(headers, "content-length", 0),
379 {size, _} <- Integer.parse(size),
380 true <- size <= limit do
384 {:error, :body_too_large}
391 defp header_length_constraint(_, _), do: :ok
393 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
394 {:error, :body_too_large}
397 defp body_size_constraint(_, _), do: :ok
399 defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max)
401 defp check_read_duration(duration, max)
402 when is_integer(duration) and is_integer(max) and max > 0 do
404 {:error, :read_duration_exceeded}
406 {:ok, {duration, :erlang.system_time(:millisecond)}}
410 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
412 defp increase_read_duration({previous_duration, started})
413 when is_integer(previous_duration) and is_integer(started) do
414 duration = :erlang.system_time(:millisecond) - started
415 {:ok, previous_duration + duration}
418 defp increase_read_duration(_) do
419 {:ok, :no_duration_limit, :no_duration_limit}
422 defp client, do: Pleroma.ReverseProxy.Client
424 defp track_failed_url(url, error, opts) do
426 unless error in [:body_too_large, 400, 204] do
427 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
432 @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)