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
8 @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++
9 ~w(if-unmodified-since if-none-match if-range range)
10 @resp_cache_headers ~w(etag date last-modified)
11 @keep_resp_headers @resp_cache_headers ++
12 ~w(content-type content-disposition content-encoding content-range) ++
13 ~w(accept-ranges vary)
14 @default_cache_control_header "public, max-age=1209600"
15 @valid_resp_codes [200, 206, 304]
16 @max_read_duration :timer.seconds(30)
17 @max_body_length :infinity
18 @failed_request_ttl :timer.seconds(60)
24 Pleroma.ReverseProxy.call(conn, url, options)
26 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
28 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
30 Responses are chunked to the client while downloading from the upstream.
32 Some request / responses headers are preserved:
34 * request: `#{inspect(@keep_req_headers)}`
35 * response: `#{inspect(@keep_resp_headers)}`
39 * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
40 errors. Any error during body processing will not be redirected as the response is chunked. This may expose
41 remote URL, clients IPs, ….
43 * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
44 specified length. It is validated with the `content-length` header and also verified when proxying.
46 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
47 read from the remote upstream.
49 * `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried.
51 * `inline_content_types`:
52 * `true` will not alter `content-disposition` (up to the upstream),
53 * `false` will add `content-disposition: attachment` to any request,
54 * a list of whitelisted content types
56 * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
57 doing content transformation (encoding, …) depending on the request.
59 * `req_headers`, `resp_headers` additional headers.
61 * `http`: options for [hackney](https://github.com/benoitc/hackney).
64 @default_hackney_options [pool: :media]
66 @inline_content_types [
83 {:keep_user_agent, boolean}
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
98 Pleroma.HTTP.Connection.hackney_options([])
99 |> Keyword.merge(@default_hackney_options)
100 |> Keyword.merge(Keyword.get(opts, :http, []))
101 |> HTTP.process_request_options()
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, hackney_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, hackney_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, "", hackney_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}}
178 defp response(conn, client, url, status, headers, opts) do
181 |> put_resp_headers(build_resp_headers(headers, opts))
182 |> send_chunked(status)
183 |> chunk_reply(client, opts)
189 {:error, :closed, conn} ->
190 client().close(client)
193 {:error, error, conn} ->
195 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
198 client().close(client)
203 defp chunk_reply(conn, client, opts) do
204 chunk_reply(conn, client, opts, 0, 0)
207 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
208 with {:ok, duration} <-
211 Keyword.get(opts, :max_read_duration, @max_read_duration)
213 {:ok, data} <- client().stream_body(client),
214 {:ok, duration} <- increase_read_duration(duration),
215 sent_so_far = sent_so_far + byte_size(data),
217 body_size_constraint(
219 Keyword.get(opts, :max_body_length, @max_body_length)
221 {:ok, conn} <- chunk(conn, data) do
222 chunk_reply(conn, client, opts, sent_so_far, duration)
225 {:error, error} -> {:error, error, conn}
229 defp head_response(conn, _url, code, headers, opts) do
231 |> put_resp_headers(build_resp_headers(headers, opts))
232 |> send_resp(code, "")
235 defp error_or_redirect(conn, url, code, body, opts) do
236 if Keyword.get(opts, :redirect_on_failure, false) do
238 |> Phoenix.Controller.redirect(external: url)
242 |> send_resp(code, body)
247 defp downcase_headers(headers) do
248 Enum.map(headers, fn {k, v} ->
249 {String.downcase(k), v}
253 defp get_content_type(headers) do
255 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
257 [content_type | _] = String.split(content_type, ";")
261 defp put_resp_headers(conn, headers) do
262 Enum.reduce(headers, conn, fn {k, v}, conn ->
263 put_resp_header(conn, k, v)
267 defp build_req_headers(headers, opts) do
269 |> downcase_headers()
270 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
272 headers = headers ++ Keyword.get(opts, :req_headers, [])
274 if Keyword.get(opts, :keep_user_agent, false) do
279 {"user-agent", Pleroma.Application.user_agent()}
287 defp build_resp_headers(headers, opts) do
289 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
290 |> build_resp_cache_headers(opts)
291 |> build_resp_content_disposition_header(opts)
292 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
295 defp build_resp_cache_headers(headers, _opts) do
296 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
300 # There's caching header present but no cache-control -- we need to explicitely override it
301 # to public as Plug defaults to "max-age=0, private, must-revalidate"
302 List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
309 {"cache-control", @default_cache_control_header}
314 defp build_resp_content_disposition_header(headers, opts) do
315 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
317 content_type = get_content_type(headers)
321 is_list(opt) && !Enum.member?(opt, content_type) -> true
329 {{"content-disposition", content_disposition_string}, _} =
330 List.keytake(headers, "content-disposition", 0)
334 ~r/filename="((?:[^"\\]|\\.)*)"/u,
335 content_disposition_string || "",
336 capture: :all_but_first
341 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
344 disposition = "attachment; filename=\"#{name}\""
346 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
352 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
353 with {_, size} <- List.keyfind(headers, "content-length", 0),
354 {size, _} <- Integer.parse(size),
355 true <- size <= limit do
359 {:error, :body_too_large}
366 defp header_length_constraint(_, _), do: :ok
368 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
369 {:error, :body_too_large}
372 defp body_size_constraint(_, _), do: :ok
374 defp check_read_duration(duration, max)
375 when is_integer(duration) and is_integer(max) and max > 0 do
377 {:error, :read_duration_exceeded}
379 {:ok, {duration, :erlang.system_time(:millisecond)}}
383 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
385 defp increase_read_duration({previous_duration, started})
386 when is_integer(previous_duration) and is_integer(started) do
387 duration = :erlang.system_time(:millisecond) - started
388 {:ok, previous_duration + duration}
391 defp increase_read_duration(_) do
392 {:ok, :no_duration_limit, :no_duration_limit}
395 defp client, do: Pleroma.ReverseProxy.Client
397 defp track_failed_url(url, error, opts) do
399 unless error in [:body_too_large, 400, 204] do
400 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
405 Cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)