1 defmodule Pleroma.ReverseProxy do
2 @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since if-unmodified-since if-none-match if-range range)
3 @resp_cache_headers ~w(etag date last-modified cache-control)
4 @keep_resp_headers @resp_cache_headers ++
5 ~w(content-type content-disposition content-encoding content-range accept-ranges vary)
6 @default_cache_control_header "public, max-age=1209600"
7 @valid_resp_codes [200, 206, 304]
8 @max_read_duration :timer.seconds(30)
9 @max_body_length :infinity
15 Pleroma.ReverseProxy.call(conn, url, options)
17 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
19 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
21 Responses are chunked to the client while downloading from the upstream.
23 Some request / responses headers are preserved:
25 * request: `#{inspect(@keep_req_headers)}`
26 * response: `#{inspect(@keep_resp_headers)}`
28 If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be
29 set to `#{inspect(@default_cache_control_header)}`.
33 * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
34 errors. Any error during body processing will not be redirected as the response is chunked. This may expose
35 remote URL, clients IPs, ….
37 * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
38 specified length. It is validated with the `content-length` header and also verified when proxying.
40 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
41 read from the remote upstream.
43 * `inline_content_types`:
44 * `true` will not alter `content-disposition` (up to the upstream),
45 * `false` will add `content-disposition: attachment` to any request,
46 * a list of whitelisted content types
48 * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
49 doing content transformation (encoding, …) depending on the request.
51 * `req_headers`, `resp_headers` additional headers.
53 * `http`: options for [hackney](https://github.com/benoitc/hackney).
56 @hackney Application.get_env(:pleroma, :hackney, :hackney)
57 @httpoison Application.get_env(:pleroma, :httpoison, HTTPoison)
59 @default_hackney_options [{:follow_redirect, true}]
61 @inline_content_types [
78 {:keep_user_agent, boolean}
79 | {:max_read_duration, :timer.time() | :infinity}
80 | {:max_body_length, non_neg_integer() | :infinity}
82 | {:req_headers, [{String.t(), String.t()}]}
83 | {:resp_headers, [{String.t(), String.t()}]}
84 | {:inline_content_types, boolean() | [String.t()]}
85 | {:redirect_on_failure, boolean()}
87 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
88 def call(conn = %{method: method}, url, opts \\ []) when method in @methods do
90 @default_hackney_options
91 |> Keyword.merge(Keyword.get(opts, :http, []))
92 |> @httpoison.process_request_options()
94 req_headers = build_req_headers(conn.req_headers, opts)
97 if filename = Pleroma.Web.MediaProxy.filename(url) do
98 Keyword.put_new(opts, :attachment_name, filename)
103 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
104 :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
105 response(conn, client, url, code, headers, opts)
107 {:ok, code, headers} ->
108 head_response(conn, url, code, headers, opts)
111 {:error, {:invalid_http_response, code}} ->
112 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
115 |> error_or_redirect(
118 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
124 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
127 |> error_or_redirect(url, 500, "Request failed", opts)
132 def call(conn, _, _) do
134 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
138 defp request(method, url, headers, hackney_opts) do
139 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
140 method = method |> String.downcase() |> String.to_existing_atom()
142 case @hackney.request(method, url, headers, "", hackney_opts) do
143 {:ok, code, headers, client} when code in @valid_resp_codes ->
144 {:ok, code, downcase_headers(headers), client}
146 {:ok, code, headers} when code in @valid_resp_codes ->
147 {:ok, code, downcase_headers(headers)}
150 {:error, {:invalid_http_response, code}}
157 defp response(conn, client, url, status, headers, opts) do
160 |> put_resp_headers(build_resp_headers(headers, opts))
161 |> send_chunked(status)
162 |> chunk_reply(client, opts)
168 {:error, :closed, conn} ->
169 :hackney.close(client)
172 {:error, error, conn} ->
174 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
177 :hackney.close(client)
182 defp chunk_reply(conn, client, opts) do
183 chunk_reply(conn, client, opts, 0, 0)
186 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
187 with {:ok, duration} <-
190 Keyword.get(opts, :max_read_duration, @max_read_duration)
192 {:ok, data} <- @hackney.stream_body(client),
193 {:ok, duration} <- increase_read_duration(duration),
194 sent_so_far = sent_so_far + byte_size(data),
195 :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
196 {:ok, conn} <- chunk(conn, data) do
197 chunk_reply(conn, client, opts, sent_so_far, duration)
200 {:error, error} -> {:error, error, conn}
204 defp head_response(conn, _url, code, headers, opts) do
206 |> put_resp_headers(build_resp_headers(headers, opts))
207 |> send_resp(code, "")
210 defp error_or_redirect(conn, url, code, body, opts) do
211 if Keyword.get(opts, :redirect_on_failure, false) do
213 |> Phoenix.Controller.redirect(external: url)
217 |> send_resp(code, body)
222 defp downcase_headers(headers) do
223 Enum.map(headers, fn {k, v} ->
224 {String.downcase(k), v}
228 defp get_content_type(headers) do
230 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
232 [content_type | _] = String.split(content_type, ";")
236 defp put_resp_headers(conn, headers) do
237 Enum.reduce(headers, conn, fn {k, v}, conn ->
238 put_resp_header(conn, k, v)
242 defp build_req_headers(headers, opts) do
245 |> downcase_headers()
246 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
248 headers = headers ++ Keyword.get(opts, :req_headers, [])
250 if Keyword.get(opts, :keep_user_agent, false) do
255 {"user-agent", Pleroma.Application.user_agent()}
263 defp build_resp_headers(headers, opts) do
265 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
266 |> build_resp_cache_headers(opts)
267 |> build_resp_content_disposition_header(opts)
268 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
271 defp build_resp_cache_headers(headers, opts) do
272 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
277 List.keystore(headers, "cache-control", 0, {"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
294 disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment")
295 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
301 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
302 with {_, size} <- List.keyfind(headers, "content-length", 0),
303 {size, _} <- Integer.parse(size),
304 true <- size <= limit do
308 {:error, :body_too_large}
315 defp header_length_constraint(_, _), do: :ok
317 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
318 {:error, :body_too_large}
321 defp body_size_constraint(_, _), do: :ok
323 defp check_read_duration(duration, max)
324 when is_integer(duration) and is_integer(max) and max > 0 do
326 {:error, :read_duration_exceeded}
328 {:ok, {duration, :erlang.system_time(:millisecond)}}
332 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
334 defp increase_read_duration({previous_duration, started})
335 when is_integer(previous_duration) and is_integer(started) do
336 duration = :erlang.system_time(:millisecond) - started
337 {:ok, previous_duration + duration}
340 defp increase_read_duration(_) do
341 {:ok, :no_duration_limit, :no_duration_limit}