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
6 @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++
7 ~w(if-unmodified-since if-none-match if-range range)
8 @resp_cache_headers ~w(etag date last-modified)
9 @keep_resp_headers @resp_cache_headers ++
10 ~w(content-type content-disposition content-encoding content-range) ++
11 ~w(accept-ranges vary)
12 @default_cache_control_header "public, max-age=1209600"
13 @valid_resp_codes [200, 206, 304]
14 @max_read_duration :timer.seconds(30)
15 @max_body_length :infinity
16 @failed_request_ttl :timer.seconds(60)
19 def max_read_duration_default, do: @max_read_duration
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) or [gun](https://github.com/ninenines/gun).
64 @default_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
97 client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, []))
99 req_headers = build_req_headers(conn.req_headers, opts)
102 if filename = Pleroma.Web.MediaProxy.filename(url) do
103 Keyword.put_new(opts, :attachment_name, filename)
108 with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url),
109 {:ok, code, headers, client} <- request(method, url, req_headers, client_opts),
111 header_length_constraint(
113 Keyword.get(opts, :max_body_length, @max_body_length)
115 response(conn, client, url, code, headers, opts)
119 |> error_or_redirect(url, 500, "Request failed", opts)
122 {:ok, code, headers} ->
123 head_response(conn, url, code, headers, opts)
126 {:error, {:invalid_http_response, code}} ->
127 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
128 track_failed_url(url, code, opts)
131 |> error_or_redirect(
134 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
140 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
141 track_failed_url(url, error, opts)
144 |> error_or_redirect(url, 500, "Request failed", opts)
149 def call(conn, _, _) do
151 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
155 defp request(method, url, headers, opts) do
156 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
157 method = method |> String.downcase() |> String.to_existing_atom()
159 case client().request(method, url, headers, "", opts) do
160 {:ok, code, headers, client} when code in @valid_resp_codes ->
161 {:ok, code, downcase_headers(headers), client}
163 {:ok, code, headers} when code in @valid_resp_codes ->
164 {:ok, code, downcase_headers(headers)}
167 {:error, {:invalid_http_response, code}}
174 defp response(conn, client, url, status, headers, opts) do
177 |> put_resp_headers(build_resp_headers(headers, opts))
178 |> send_chunked(status)
179 |> chunk_reply(client, opts)
185 {:error, :closed, conn} ->
186 client().close(client)
189 {:error, error, conn} ->
191 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
194 client().close(client)
199 defp chunk_reply(conn, client, opts) do
200 chunk_reply(conn, client, opts, 0, 0)
203 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
204 with {:ok, duration} <-
207 Keyword.get(opts, :max_read_duration, @max_read_duration)
209 {:ok, data, client} <- client().stream_body(client),
210 {:ok, duration} <- increase_read_duration(duration),
211 sent_so_far = sent_so_far + byte_size(data),
213 body_size_constraint(
215 Keyword.get(opts, :max_body_length, @max_body_length)
217 {:ok, conn} <- chunk(conn, data) do
218 chunk_reply(conn, client, opts, sent_so_far, duration)
221 {:error, error} -> {:error, error, conn}
225 defp head_response(conn, _url, code, headers, opts) do
227 |> put_resp_headers(build_resp_headers(headers, opts))
228 |> send_resp(code, "")
231 defp error_or_redirect(conn, url, code, body, opts) do
232 if Keyword.get(opts, :redirect_on_failure, false) do
234 |> Phoenix.Controller.redirect(external: url)
238 |> send_resp(code, body)
243 defp downcase_headers(headers) do
244 Enum.map(headers, fn {k, v} ->
245 {String.downcase(k), v}
249 defp get_content_type(headers) do
251 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
253 [content_type | _] = String.split(content_type, ";")
257 defp put_resp_headers(conn, headers) do
258 Enum.reduce(headers, conn, fn {k, v}, conn ->
259 put_resp_header(conn, k, v)
263 defp build_req_headers(headers, opts) do
265 |> downcase_headers()
266 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
268 headers = headers ++ Keyword.get(opts, :req_headers, [])
270 if Keyword.get(opts, :keep_user_agent, false) do
275 {"user-agent", Pleroma.Application.user_agent()}
283 defp build_resp_headers(headers, opts) do
285 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
286 |> build_resp_cache_headers(opts)
287 |> build_resp_content_disposition_header(opts)
288 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
291 defp build_resp_cache_headers(headers, _opts) do
292 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
296 # There's caching header present but no cache-control -- we need to set our own
297 # as Plug defaults to "max-age=0, private, must-revalidate"
302 {"cache-control", @default_cache_control_header}
310 {"cache-control", @default_cache_control_header}
315 defp build_resp_content_disposition_header(headers, opts) do
316 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
318 content_type = get_content_type(headers)
322 is_list(opt) && !Enum.member?(opt, content_type) -> true
330 {{"content-disposition", content_disposition_string}, _} =
331 List.keytake(headers, "content-disposition", 0)
335 ~r/filename="((?:[^"\\]|\\.)*)"/u,
336 content_disposition_string || "",
337 capture: :all_but_first
342 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
345 disposition = "attachment; filename=\"#{name}\""
347 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
353 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
354 with {_, size} <- List.keyfind(headers, "content-length", 0),
355 {size, _} <- Integer.parse(size),
356 true <- size <= limit do
360 {:error, :body_too_large}
367 defp header_length_constraint(_, _), do: :ok
369 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
370 {:error, :body_too_large}
373 defp body_size_constraint(_, _), do: :ok
375 defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max)
377 defp check_read_duration(duration, max)
378 when is_integer(duration) and is_integer(max) and max > 0 do
380 {:error, :read_duration_exceeded}
382 {:ok, {duration, :erlang.system_time(:millisecond)}}
386 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
388 defp increase_read_duration({previous_duration, started})
389 when is_integer(previous_duration) and is_integer(started) do
390 duration = :erlang.system_time(:millisecond) - started
391 {:ok, previous_duration + duration}
394 defp increase_read_duration(_) do
395 {:ok, :no_duration_limit, :no_duration_limit}
398 defp client, do: Pleroma.ReverseProxy.Client
400 defp track_failed_url(url, error, opts) do
402 unless error in [:body_too_large, 400, 204] do
403 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
408 Cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)