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 []
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, _url, _opts \\ [])
90 def call(conn = %{method: method}, url, opts) when method in @methods do
92 @default_hackney_options
93 |> Keyword.merge(Keyword.get(opts, :http, []))
94 |> @httpoison.process_request_options()
96 req_headers = build_req_headers(conn.req_headers, opts)
99 if filename = Pleroma.Web.MediaProxy.filename(url) do
100 Keyword.put_new(opts, :attachment_name, filename)
105 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
106 :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
107 response(conn, client, url, code, headers, opts)
109 {:ok, code, headers} ->
110 head_response(conn, url, code, headers, opts)
113 {:error, {:invalid_http_response, code}} ->
114 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
117 |> error_or_redirect(
120 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
126 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
129 |> error_or_redirect(url, 500, "Request failed", opts)
134 def call(conn, _, _) do
136 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
140 defp request(method, url, headers, hackney_opts) do
141 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
142 method = method |> String.downcase() |> String.to_existing_atom()
144 case @hackney.request(method, url, headers, "", hackney_opts) do
145 {:ok, code, headers, client} when code in @valid_resp_codes ->
146 {:ok, code, downcase_headers(headers), client}
148 {:ok, code, headers} when code in @valid_resp_codes ->
149 {:ok, code, downcase_headers(headers)}
152 {:error, {:invalid_http_response, code}}
159 defp response(conn, client, url, status, headers, opts) do
162 |> put_resp_headers(build_resp_headers(headers, opts))
163 |> send_chunked(status)
164 |> chunk_reply(client, opts)
170 {:error, :closed, conn} ->
171 :hackney.close(client)
174 {:error, error, conn} ->
176 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
179 :hackney.close(client)
184 defp chunk_reply(conn, client, opts) do
185 chunk_reply(conn, client, opts, 0, 0)
188 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
189 with {:ok, duration} <-
192 Keyword.get(opts, :max_read_duration, @max_read_duration)
194 {:ok, data} <- @hackney.stream_body(client),
195 {:ok, duration} <- increase_read_duration(duration),
196 sent_so_far = sent_so_far + byte_size(data),
197 :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
198 {:ok, conn} <- chunk(conn, data) do
199 chunk_reply(conn, client, opts, sent_so_far, duration)
202 {:error, error} -> {:error, error, conn}
206 defp head_response(conn, _url, code, headers, opts) do
208 |> put_resp_headers(build_resp_headers(headers, opts))
209 |> send_resp(code, "")
212 defp error_or_redirect(conn, url, code, body, opts) do
213 if Keyword.get(opts, :redirect_on_failure, false) do
215 |> Phoenix.Controller.redirect(external: url)
219 |> send_resp(code, body)
224 defp downcase_headers(headers) do
225 Enum.map(headers, fn {k, v} ->
226 {String.downcase(k), v}
230 defp get_content_type(headers) do
232 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
234 [content_type | _] = String.split(content_type, ";")
238 defp put_resp_headers(conn, headers) do
239 Enum.reduce(headers, conn, fn {k, v}, conn ->
240 put_resp_header(conn, k, v)
244 defp build_req_headers(headers, opts) do
246 |> downcase_headers()
247 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
249 headers = headers ++ Keyword.get(opts, :req_headers, [])
251 if Keyword.get(opts, :keep_user_agent, false) do
256 {"user-agent", Pleroma.Application.user_agent()}
264 defp build_resp_headers(headers, opts) do
266 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
267 |> build_resp_cache_headers(opts)
268 |> build_resp_content_disposition_header(opts)
269 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
272 defp build_resp_cache_headers(headers, _opts) do
273 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
278 List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
282 defp build_resp_content_disposition_header(headers, opts) do
283 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
285 content_type = get_content_type(headers)
289 is_list(opt) && !Enum.member?(opt, content_type) -> true
295 disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment")
296 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
302 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
303 with {_, size} <- List.keyfind(headers, "content-length", 0),
304 {size, _} <- Integer.parse(size),
305 true <- size <= limit do
309 {:error, :body_too_large}
316 defp header_length_constraint(_, _), do: :ok
318 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
319 {:error, :body_too_large}
322 defp body_size_constraint(_, _), do: :ok
324 defp check_read_duration(duration, max)
325 when is_integer(duration) and is_integer(max) and max > 0 do
327 {:error, :read_duration_exceeded}
329 {:ok, {duration, :erlang.system_time(:millisecond)}}
333 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
335 defp increase_read_duration({previous_duration, started})
336 when is_integer(previous_duration) and is_integer(started) do
337 duration = :erlang.system_time(:millisecond) - started
338 {:ok, previous_duration + duration}
341 defp increase_read_duration(_) do
342 {:ok, :no_duration_limit, :no_duration_limit}