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 []
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 @default_hackney_options
98 |> Keyword.merge(Keyword.get(opts, :http, []))
99 |> HTTP.process_request_options()
101 req_headers = build_req_headers(conn.req_headers, opts)
104 if filename = Pleroma.Web.MediaProxy.filename(url) do
105 Keyword.put_new(opts, :attachment_name, filename)
110 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
111 :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
112 response(conn, client, url, code, headers, opts)
114 {:ok, code, headers} ->
115 head_response(conn, url, code, headers, opts)
118 {:error, {:invalid_http_response, code}} ->
119 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
122 |> error_or_redirect(
125 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
131 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
134 |> error_or_redirect(url, 500, "Request failed", opts)
139 def call(conn, _, _) do
141 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
145 defp request(method, url, headers, hackney_opts) do
146 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
147 method = method |> String.downcase() |> String.to_existing_atom()
149 case :hackney.request(method, url, headers, "", hackney_opts) do
150 {:ok, code, headers, client} when code in @valid_resp_codes ->
151 {:ok, code, downcase_headers(headers), client}
153 {:ok, code, headers} when code in @valid_resp_codes ->
154 {:ok, code, downcase_headers(headers)}
157 {:error, {:invalid_http_response, code}}
164 defp response(conn, client, url, status, headers, opts) do
167 |> put_resp_headers(build_resp_headers(headers, opts))
168 |> send_chunked(status)
169 |> chunk_reply(client, opts)
175 {:error, :closed, conn} ->
176 :hackney.close(client)
179 {:error, error, conn} ->
181 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
184 :hackney.close(client)
189 defp chunk_reply(conn, client, opts) do
190 chunk_reply(conn, client, opts, 0, 0)
193 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
194 with {:ok, duration} <-
197 Keyword.get(opts, :max_read_duration, @max_read_duration)
199 {:ok, data} <- :hackney.stream_body(client),
200 {:ok, duration} <- increase_read_duration(duration),
201 sent_so_far = sent_so_far + byte_size(data),
202 :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
203 {:ok, conn} <- chunk(conn, data) do
204 chunk_reply(conn, client, opts, sent_so_far, duration)
207 {:error, error} -> {:error, error, conn}
211 defp head_response(conn, _url, code, headers, opts) do
213 |> put_resp_headers(build_resp_headers(headers, opts))
214 |> send_resp(code, "")
217 defp error_or_redirect(conn, url, code, body, opts) do
218 if Keyword.get(opts, :redirect_on_failure, false) do
220 |> Phoenix.Controller.redirect(external: url)
224 |> send_resp(code, body)
229 defp downcase_headers(headers) do
230 Enum.map(headers, fn {k, v} ->
231 {String.downcase(k), v}
235 defp get_content_type(headers) do
237 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
239 [content_type | _] = String.split(content_type, ";")
243 defp put_resp_headers(conn, headers) do
244 Enum.reduce(headers, conn, fn {k, v}, conn ->
245 put_resp_header(conn, k, v)
249 defp build_req_headers(headers, opts) do
251 |> downcase_headers()
252 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
254 headers = headers ++ Keyword.get(opts, :req_headers, [])
256 if Keyword.get(opts, :keep_user_agent, false) do
261 {"user-agent", Pleroma.Application.user_agent()}
269 defp build_resp_headers(headers, opts) do
271 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
272 |> build_resp_cache_headers(opts)
273 |> build_resp_content_disposition_header(opts)
274 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
277 defp build_resp_cache_headers(headers, _opts) do
278 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
279 has_cache_control? = List.keymember?(headers, "cache-control", 0)
282 has_cache? && has_cache_control? ->
286 # There's caching header present but no cache-control -- we need to explicitely override it
287 # to public as Plug defaults to "max-age=0, private, must-revalidate"
288 List.keystore(headers, "cache-control", 0, {"cache-control", "public"})
295 {"cache-control", @default_cache_control_header}
300 defp build_resp_content_disposition_header(headers, opts) do
301 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
303 content_type = get_content_type(headers)
307 is_list(opt) && !Enum.member?(opt, content_type) -> true
315 {{"content-disposition", content_disposition_string}, _} =
316 List.keytake(headers, "content-disposition", 0)
320 ~r/filename="((?:[^"\\]|\\.)*)"/u,
321 content_disposition_string || "",
322 capture: :all_but_first
327 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
330 disposition = "attachment; filename=\"#{name}\""
332 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
338 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
339 with {_, size} <- List.keyfind(headers, "content-length", 0),
340 {size, _} <- Integer.parse(size),
341 true <- size <= limit do
345 {:error, :body_too_large}
352 defp header_length_constraint(_, _), do: :ok
354 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
355 {:error, :body_too_large}
358 defp body_size_constraint(_, _), do: :ok
360 defp check_read_duration(duration, max)
361 when is_integer(duration) and is_integer(max) and max > 0 do
363 {:error, :read_duration_exceeded}
365 {:ok, {duration, :erlang.system_time(:millisecond)}}
369 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
371 defp increase_read_duration({previous_duration, started})
372 when is_integer(previous_duration) and is_integer(started) do
373 duration = :erlang.system_time(:millisecond) - started
374 {:ok, previous_duration + duration}
377 defp increase_read_duration(_) do
378 {:ok, :no_duration_limit, :no_duration_limit}