1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.ReverseProxy do
6 @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since if-unmodified-since if-none-match if-range range)
7 @resp_cache_headers ~w(etag date last-modified cache-control)
8 @keep_resp_headers @resp_cache_headers ++
9 ~w(content-type content-disposition content-encoding content-range accept-ranges vary)
10 @default_cache_control_header "public, max-age=1209600"
11 @valid_resp_codes [200, 206, 304]
12 @max_read_duration :timer.seconds(30)
13 @max_body_length :infinity
19 Pleroma.ReverseProxy.call(conn, url, options)
21 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
23 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
25 Responses are chunked to the client while downloading from the upstream.
27 Some request / responses headers are preserved:
29 * request: `#{inspect(@keep_req_headers)}`
30 * response: `#{inspect(@keep_resp_headers)}`
32 If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be
33 set to `#{inspect(@default_cache_control_header)}`.
37 * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
38 errors. Any error during body processing will not be redirected as the response is chunked. This may expose
39 remote URL, clients IPs, ….
41 * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
42 specified length. It is validated with the `content-length` header and also verified when proxying.
44 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
45 read from the remote upstream.
47 * `inline_content_types`:
48 * `true` will not alter `content-disposition` (up to the upstream),
49 * `false` will add `content-disposition: attachment` to any request,
50 * a list of whitelisted content types
52 * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
53 doing content transformation (encoding, …) depending on the request.
55 * `req_headers`, `resp_headers` additional headers.
57 * `http`: options for [hackney](https://github.com/benoitc/hackney).
60 @hackney Application.get_env(:pleroma, :hackney, :hackney)
61 @httpoison Application.get_env(:pleroma, :httpoison, HTTPoison)
63 @default_hackney_options []
65 @inline_content_types [
82 {:keep_user_agent, boolean}
83 | {:max_read_duration, :timer.time() | :infinity}
84 | {:max_body_length, non_neg_integer() | :infinity}
86 | {:req_headers, [{String.t(), String.t()}]}
87 | {:resp_headers, [{String.t(), String.t()}]}
88 | {:inline_content_types, boolean() | [String.t()]}
89 | {:redirect_on_failure, boolean()}
91 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
92 def call(_conn, _url, _opts \\ [])
94 def call(conn = %{method: method}, url, opts) when method in @methods do
96 @default_hackney_options
97 |> Keyword.merge(Keyword.get(opts, :http, []))
98 |> @httpoison.process_request_options()
100 req_headers = build_req_headers(conn.req_headers, opts)
103 if filename = Pleroma.Web.MediaProxy.filename(url) do
104 Keyword.put_new(opts, :attachment_name, filename)
109 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
110 :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
111 response(conn, client, url, code, headers, opts)
113 {:ok, code, headers} ->
114 head_response(conn, url, code, headers, opts)
117 {:error, {:invalid_http_response, code}} ->
118 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
121 |> error_or_redirect(
124 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
130 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
133 |> error_or_redirect(url, 500, "Request failed", opts)
138 def call(conn, _, _) do
140 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
144 defp request(method, url, headers, hackney_opts) do
145 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
146 method = method |> String.downcase() |> String.to_existing_atom()
148 case @hackney.request(method, url, headers, "", hackney_opts) do
149 {:ok, code, headers, client} when code in @valid_resp_codes ->
150 {:ok, code, downcase_headers(headers), client}
152 {:ok, code, headers} when code in @valid_resp_codes ->
153 {:ok, code, downcase_headers(headers)}
156 {:error, {:invalid_http_response, code}}
163 defp response(conn, client, url, status, headers, opts) do
166 |> put_resp_headers(build_resp_headers(headers, opts))
167 |> send_chunked(status)
168 |> chunk_reply(client, opts)
174 {:error, :closed, conn} ->
175 :hackney.close(client)
178 {:error, error, conn} ->
180 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
183 :hackney.close(client)
188 defp chunk_reply(conn, client, opts) do
189 chunk_reply(conn, client, opts, 0, 0)
192 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
193 with {:ok, duration} <-
196 Keyword.get(opts, :max_read_duration, @max_read_duration)
198 {:ok, data} <- @hackney.stream_body(client),
199 {:ok, duration} <- increase_read_duration(duration),
200 sent_so_far = sent_so_far + byte_size(data),
201 :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
202 {:ok, conn} <- chunk(conn, data) do
203 chunk_reply(conn, client, opts, sent_so_far, duration)
206 {:error, error} -> {:error, error, conn}
210 defp head_response(conn, _url, code, headers, opts) do
212 |> put_resp_headers(build_resp_headers(headers, opts))
213 |> send_resp(code, "")
216 defp error_or_redirect(conn, url, code, body, opts) do
217 if Keyword.get(opts, :redirect_on_failure, false) do
219 |> Phoenix.Controller.redirect(external: url)
223 |> send_resp(code, body)
228 defp downcase_headers(headers) do
229 Enum.map(headers, fn {k, v} ->
230 {String.downcase(k), v}
234 defp get_content_type(headers) do
236 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
238 [content_type | _] = String.split(content_type, ";")
242 defp put_resp_headers(conn, headers) do
243 Enum.reduce(headers, conn, fn {k, v}, conn ->
244 put_resp_header(conn, k, v)
248 defp build_req_headers(headers, opts) do
250 |> downcase_headers()
251 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
253 headers = headers ++ Keyword.get(opts, :req_headers, [])
255 if Keyword.get(opts, :keep_user_agent, false) do
260 {"user-agent", Pleroma.Application.user_agent()}
268 defp build_resp_headers(headers, opts) do
270 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
271 |> build_resp_cache_headers(opts)
272 |> build_resp_content_disposition_header(opts)
273 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
276 defp build_resp_cache_headers(headers, _opts) do
277 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
282 List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
286 defp build_resp_content_disposition_header(headers, opts) do
287 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
289 content_type = get_content_type(headers)
293 is_list(opt) && !Enum.member?(opt, content_type) -> true
299 disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment")
300 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
306 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
307 with {_, size} <- List.keyfind(headers, "content-length", 0),
308 {size, _} <- Integer.parse(size),
309 true <- size <= limit do
313 {:error, :body_too_large}
320 defp header_length_constraint(_, _), do: :ok
322 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
323 {:error, :body_too_large}
326 defp body_size_constraint(_, _), do: :ok
328 defp check_read_duration(duration, max)
329 when is_integer(duration) and is_integer(max) and max > 0 do
331 {:error, :read_duration_exceeded}
333 {:ok, {duration, :erlang.system_time(:millisecond)}}
337 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
339 defp increase_read_duration({previous_duration, started})
340 when is_integer(previous_duration) and is_integer(started) do
341 duration = :erlang.system_time(:millisecond) - started
342 {:ok, previous_duration + duration}
345 defp increase_read_duration(_) do
346 {:ok, :no_duration_limit, :no_duration_limit}