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 |> put_private(:proxied_url, url)
118 |> error_or_redirect(500, "Request failed", opts)
121 {:ok, status, headers} ->
123 |> put_private(:proxied_url, url)
124 |> head_response(status, headers, opts)
127 {:error, {:invalid_http_response, status}} ->
129 "#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{status}"
132 track_failed_url(url, status, opts)
135 |> put_private(:proxied_url, url)
136 |> error_or_redirect(
138 "Request failed: " <> Plug.Conn.Status.reason_phrase(status),
144 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
145 track_failed_url(url, error, opts)
148 |> put_private(:proxied_url, url)
149 |> error_or_redirect(500, "Request failed", opts)
154 def call(conn, _, _) do
156 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
160 defp request(method, url, headers, opts) do
161 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
162 method = method |> String.downcase() |> String.to_existing_atom()
164 opts = opts ++ [receive_timeout: @max_read_duration]
166 case Pleroma.HTTP.request(method, url, "", headers, opts) do
167 {:ok, %Tesla.Env{status: status, headers: headers, body: body}}
168 when status in @valid_resp_codes ->
169 {:ok, status, downcase_headers(headers), body}
171 {:ok, %Tesla.Env{status: status, headers: headers}} when status in @valid_resp_codes ->
172 {:ok, status, downcase_headers(headers)}
174 {:ok, %Tesla.Env{status: status}} ->
175 {:error, {:invalid_http_response, status}}
182 defp response(conn, body, status, headers, opts) do
183 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
186 |> put_resp_headers(build_resp_headers(headers, opts))
187 |> send_resp(status, body)
190 defp head_response(conn, status, headers, opts) do
191 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
194 |> put_resp_headers(build_resp_headers(headers, opts))
195 |> send_resp(status, "")
198 defp error_or_redirect(conn, status, body, opts) do
199 if Keyword.get(opts, :redirect_on_failure, false) do
201 |> Phoenix.Controller.redirect(external: conn.private[:proxied_url])
205 |> send_resp(status, body)
210 defp downcase_headers(headers) do
211 Enum.map(headers, fn {k, v} ->
212 {String.downcase(k), v}
216 defp get_content_type(headers) do
218 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
220 [content_type | _] = String.split(content_type, ";")
224 defp put_resp_headers(conn, headers) do
225 Enum.reduce(headers, conn, fn {k, v}, conn ->
226 put_resp_header(conn, k, v)
230 defp build_req_headers(headers, opts) do
232 |> downcase_headers()
233 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
234 |> build_req_range_or_encoding_header(opts)
235 |> Keyword.merge(Keyword.get(opts, :req_headers, []))
238 # Disable content-encoding if any @range_headers are requested (see #1823).
239 defp build_req_range_or_encoding_header(headers, _opts) do
240 range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
242 if range? && List.keymember?(headers, "accept-encoding", 0) do
243 List.keydelete(headers, "accept-encoding", 0)
249 defp build_resp_headers(headers, opts) do
251 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
252 |> build_resp_cache_headers(opts)
253 |> build_resp_content_disposition_header(opts)
254 |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
257 defp build_resp_cache_headers(headers, _opts) do
258 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
262 # There's caching header present but no cache-control -- we need to set our own
263 # as Plug defaults to "max-age=0, private, must-revalidate"
268 {"cache-control", @default_cache_control_header}
276 {"cache-control", @default_cache_control_header}
281 defp build_resp_content_disposition_header(headers, opts) do
282 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
284 content_type = get_content_type(headers)
288 is_list(opt) && !Enum.member?(opt, content_type) -> true
296 {{"content-disposition", content_disposition_string}, _} =
297 List.keytake(headers, "content-disposition", 0)
301 ~r/filename="((?:[^"\\]|\\.)*)"/u,
302 content_disposition_string || "",
303 capture: :all_but_first
308 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
311 disposition = "attachment; filename=\"#{name}\""
313 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
319 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
320 with {_, size} <- List.keyfind(headers, "content-length", 0),
321 {size, _} <- Integer.parse(size),
322 true <- size <= limit do
326 {:error, :body_too_large}
333 defp header_length_constraint(_, _), do: :ok
335 defp track_failed_url(url, error, opts) do
337 unless error in [:body_too_large, 400, 204] do
338 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
343 @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)