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)
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.
62 * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun).
65 @default_options [pool: :media]
67 @inline_content_types [
84 {:max_read_duration, :timer.time() | :infinity}
85 | {:max_body_length, non_neg_integer() | :infinity}
86 | {:failed_request_ttl, :timer.time() | :infinity}
88 | {:req_headers, [{String.t(), String.t()}]}
89 | {:resp_headers, [{String.t(), String.t()}]}
90 | {:inline_content_types, boolean() | [String.t()]}
91 | {:redirect_on_failure, boolean()}
93 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
94 def call(_conn, _url, _opts \\ [])
96 def call(conn = %{method: method}, url, opts) when method in @methods do
97 client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, []))
99 req_headers = build_req_headers(conn.req_headers, opts)
102 if filename = Pleroma.Web.MediaProxy.filename(url) do
103 Keyword.put_new(opts, :attachment_name, filename)
108 with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url),
109 {:ok, code, headers, client} <- request(method, url, req_headers, client_opts),
111 header_length_constraint(
113 Keyword.get(opts, :max_body_length, @max_body_length)
115 response(conn, client, url, code, headers, opts)
119 |> error_or_redirect(url, 500, "Request failed", opts)
122 {:ok, code, headers} ->
123 head_response(conn, url, code, headers, opts)
126 {:error, {:invalid_http_response, code}} ->
127 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
128 track_failed_url(url, code, opts)
131 |> error_or_redirect(
134 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
140 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
141 track_failed_url(url, error, opts)
144 |> error_or_redirect(url, 500, "Request failed", opts)
149 def call(conn, _, _) do
151 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
155 defp request(method, url, headers, opts) do
156 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
157 method = method |> String.downcase() |> String.to_existing_atom()
159 case client().request(method, url, headers, "", opts) do
160 {:ok, code, headers, client} when code in @valid_resp_codes ->
161 {:ok, code, downcase_headers(headers), client}
163 {:ok, code, headers} when code in @valid_resp_codes ->
164 {:ok, code, downcase_headers(headers)}
167 {:error, {:invalid_http_response, code}}
170 {:error, {:invalid_http_response, code}}
177 defp response(conn, client, url, status, headers, opts) do
178 Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
182 |> put_resp_headers(build_resp_headers(headers, opts))
183 |> send_chunked(status)
184 |> chunk_reply(client, opts)
190 {:error, :closed, conn} ->
191 client().close(client)
194 {:error, error, conn} ->
196 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
199 client().close(client)
204 defp chunk_reply(conn, client, opts) do
205 chunk_reply(conn, client, opts, 0, 0)
208 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
209 with {:ok, duration} <-
212 Keyword.get(opts, :max_read_duration, @max_read_duration)
214 {:ok, data, client} <- client().stream_body(client),
215 {:ok, duration} <- increase_read_duration(duration),
216 sent_so_far = sent_so_far + byte_size(data),
218 body_size_constraint(
220 Keyword.get(opts, :max_body_length, @max_body_length)
222 {:ok, conn} <- chunk(conn, data) do
223 chunk_reply(conn, client, opts, sent_so_far, duration)
226 {:error, error} -> {:error, error, conn}
230 defp head_response(conn, url, code, headers, opts) do
231 Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
234 |> put_resp_headers(build_resp_headers(headers, opts))
235 |> send_resp(code, "")
238 defp error_or_redirect(conn, url, code, body, opts) do
239 if Keyword.get(opts, :redirect_on_failure, false) do
241 |> Phoenix.Controller.redirect(external: url)
245 |> send_resp(code, body)
250 defp downcase_headers(headers) do
251 Enum.map(headers, fn {k, v} ->
252 {String.downcase(k), v}
256 defp get_content_type(headers) do
258 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
260 [content_type | _] = String.split(content_type, ";")
264 defp put_resp_headers(conn, headers) do
265 Enum.reduce(headers, conn, fn {k, v}, conn ->
266 put_resp_header(conn, k, v)
270 defp build_req_headers(headers, opts) do
272 |> downcase_headers()
273 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
274 |> build_req_range_or_encoding_header(opts)
275 |> build_req_user_agent_header(opts)
276 |> Keyword.merge(Keyword.get(opts, :req_headers, []))
279 # Disable content-encoding if any @range_headers are requested (see #1823).
280 defp build_req_range_or_encoding_header(headers, _opts) do
281 range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
283 if range? && List.keymember?(headers, "accept-encoding", 0) do
284 List.keydelete(headers, "accept-encoding", 0)
290 defp build_req_user_agent_header(headers, _opts) do
295 {"user-agent", Pleroma.Application.user_agent()}
299 defp build_resp_headers(headers, opts) do
301 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
302 |> build_resp_cache_headers(opts)
303 |> build_resp_content_disposition_header(opts)
304 |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
307 defp build_resp_cache_headers(headers, _opts) do
308 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
312 # There's caching header present but no cache-control -- we need to set our own
313 # as Plug defaults to "max-age=0, private, must-revalidate"
318 {"cache-control", @default_cache_control_header}
326 {"cache-control", @default_cache_control_header}
331 defp build_resp_content_disposition_header(headers, opts) do
332 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
334 content_type = get_content_type(headers)
338 is_list(opt) && !Enum.member?(opt, content_type) -> true
346 {{"content-disposition", content_disposition_string}, _} =
347 List.keytake(headers, "content-disposition", 0)
351 ~r/filename="((?:[^"\\]|\\.)*)"/u,
352 content_disposition_string || "",
353 capture: :all_but_first
358 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
361 disposition = "attachment; filename=\"#{name}\""
363 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
369 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
370 with {_, size} <- List.keyfind(headers, "content-length", 0),
371 {size, _} <- Integer.parse(size),
372 true <- size <= limit do
376 {:error, :body_too_large}
383 defp header_length_constraint(_, _), do: :ok
385 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
386 {:error, :body_too_large}
389 defp body_size_constraint(_, _), do: :ok
391 defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max)
393 defp check_read_duration(duration, max)
394 when is_integer(duration) and is_integer(max) and max > 0 do
396 {:error, :read_duration_exceeded}
398 {:ok, {duration, :erlang.system_time(:millisecond)}}
402 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
404 defp increase_read_duration({previous_duration, started})
405 when is_integer(previous_duration) and is_integer(started) do
406 duration = :erlang.system_time(:millisecond) - started
407 {:ok, previous_duration + duration}
410 defp increase_read_duration(_) do
411 {:ok, :no_duration_limit, :no_duration_limit}
414 defp client, do: Pleroma.ReverseProxy.Client
416 defp track_failed_url(url, error, opts) do
418 unless error in [:body_too_large, 400, 204] do
419 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
424 @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)