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
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 cache-control)
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
23 Pleroma.ReverseProxy.call(conn, url, options)
25 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
27 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
29 Responses are chunked to the client while downloading from the upstream.
31 Some request / responses headers are preserved:
33 * request: `#{inspect(@keep_req_headers)}`
34 * response: `#{inspect(@keep_resp_headers)}`
36 If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be
37 set to `#{inspect(@default_cache_control_header)}`.
41 * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
42 errors. Any error during body processing will not be redirected as the response is chunked. This may expose
43 remote URL, clients IPs, ….
45 * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
46 specified length. It is validated with the `content-length` header and also verified when proxying.
48 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
49 read from the remote upstream.
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}
87 | {:req_headers, [{String.t(), String.t()}]}
88 | {:resp_headers, [{String.t(), String.t()}]}
89 | {:inline_content_types, boolean() | [String.t()]}
90 | {:redirect_on_failure, boolean()}
92 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
93 def call(_conn, _url, _opts \\ [])
95 def call(conn = %{method: method}, url, opts) when method in @methods do
97 Pleroma.HTTP.Connection.hackney_options([])
98 |> Keyword.merge(@default_hackney_options)
99 |> Keyword.merge(Keyword.get(opts, :http, []))
100 |> HTTP.process_request_options()
102 req_headers = build_req_headers(conn.req_headers, opts)
105 if filename = Pleroma.Web.MediaProxy.filename(url) do
106 Keyword.put_new(opts, :attachment_name, filename)
111 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
113 header_length_constraint(
115 Keyword.get(opts, :max_body_length, @max_body_length)
117 response(conn, client, url, code, headers, opts)
119 {:ok, code, headers} ->
120 head_response(conn, url, code, headers, opts)
123 {:error, {:invalid_http_response, code}} ->
124 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
127 |> error_or_redirect(
130 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
136 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
139 |> error_or_redirect(url, 500, "Request failed", opts)
144 def call(conn, _, _) do
146 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
150 defp request(method, url, headers, hackney_opts) do
151 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
152 method = method |> String.downcase() |> String.to_existing_atom()
154 case client().request(method, url, headers, "", hackney_opts) do
155 {:ok, code, headers, client} when code in @valid_resp_codes ->
156 {:ok, code, downcase_headers(headers), client}
158 {:ok, code, headers} when code in @valid_resp_codes ->
159 {:ok, code, downcase_headers(headers)}
162 {:error, {:invalid_http_response, code}}
169 defp response(conn, client, url, status, headers, opts) do
172 |> put_resp_headers(build_resp_headers(headers, opts))
173 |> send_chunked(status)
174 |> chunk_reply(client, opts)
180 {:error, :closed, conn} ->
181 client().close(client)
184 {:error, error, conn} ->
186 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
189 client().close(client)
194 defp chunk_reply(conn, client, opts) do
195 chunk_reply(conn, client, opts, 0, 0)
198 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
199 with {:ok, duration} <-
202 Keyword.get(opts, :max_read_duration, @max_read_duration)
204 {:ok, data} <- client().stream_body(client),
205 {:ok, duration} <- increase_read_duration(duration),
206 sent_so_far = sent_so_far + byte_size(data),
208 body_size_constraint(
210 Keyword.get(opts, :max_body_length, @max_body_length)
212 {:ok, conn} <- chunk(conn, data) do
213 chunk_reply(conn, client, opts, sent_so_far, duration)
216 {:error, error} -> {:error, error, conn}
220 defp head_response(conn, _url, code, headers, opts) do
222 |> put_resp_headers(build_resp_headers(headers, opts))
223 |> send_resp(code, "")
226 defp error_or_redirect(conn, url, code, body, opts) do
227 if Keyword.get(opts, :redirect_on_failure, false) do
229 |> Phoenix.Controller.redirect(external: url)
233 |> send_resp(code, body)
238 defp downcase_headers(headers) do
239 Enum.map(headers, fn {k, v} ->
240 {String.downcase(k), v}
244 defp get_content_type(headers) do
246 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
248 [content_type | _] = String.split(content_type, ";")
252 defp put_resp_headers(conn, headers) do
253 Enum.reduce(headers, conn, fn {k, v}, conn ->
254 put_resp_header(conn, k, v)
258 defp build_req_headers(headers, opts) do
260 |> downcase_headers()
261 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
263 headers = headers ++ Keyword.get(opts, :req_headers, [])
265 if Keyword.get(opts, :keep_user_agent, false) do
270 {"user-agent", Pleroma.Application.user_agent()}
278 defp build_resp_headers(headers, opts) do
280 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
281 |> build_resp_cache_headers(opts)
282 |> build_resp_content_disposition_header(opts)
283 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
286 defp build_resp_cache_headers(headers, _opts) do
287 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
288 has_cache_control? = List.keymember?(headers, "cache-control", 0)
291 has_cache? && has_cache_control? ->
295 # There's caching header present but no cache-control -- we need to explicitely override it
296 # to public as Plug defaults to "max-age=0, private, must-revalidate"
297 List.keystore(headers, "cache-control", 0, {"cache-control", "public"})
304 {"cache-control", @default_cache_control_header}
309 defp build_resp_content_disposition_header(headers, opts) do
310 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
312 content_type = get_content_type(headers)
316 is_list(opt) && !Enum.member?(opt, content_type) -> true
324 {{"content-disposition", content_disposition_string}, _} =
325 List.keytake(headers, "content-disposition", 0)
329 ~r/filename="((?:[^"\\]|\\.)*)"/u,
330 content_disposition_string || "",
331 capture: :all_but_first
336 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
339 disposition = "attachment; filename=\"#{name}\""
341 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
347 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
348 with {_, size} <- List.keyfind(headers, "content-length", 0),
349 {size, _} <- Integer.parse(size),
350 true <- size <= limit do
354 {:error, :body_too_large}
361 defp header_length_constraint(_, _), do: :ok
363 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
364 {:error, :body_too_large}
367 defp body_size_constraint(_, _), do: :ok
369 defp check_read_duration(duration, max)
370 when is_integer(duration) and is_integer(max) and max > 0 do
372 {:error, :read_duration_exceeded}
374 {:ok, {duration, :erlang.system_time(:millisecond)}}
378 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
380 defp increase_read_duration({previous_duration, started})
381 when is_integer(previous_duration) and is_integer(started) do
382 duration = :erlang.system_time(:millisecond) - started
383 {:ok, previous_duration + duration}
386 defp increase_read_duration(_) do
387 {:ok, :no_duration_limit, :no_duration_limit}
390 defp client, do: Pleroma.ReverseProxy.Client