Merge branch 'features/add-credo-to-ci' into 'develop'
[akkoma] / lib / pleroma / reverse_proxy.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
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 cache-control)
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 @methods ~w(GET HEAD)
17
18 @moduledoc """
19 A reverse proxy.
20
21 Pleroma.ReverseProxy.call(conn, url, options)
22
23 It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
24
25 Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
26
27 Responses are chunked to the client while downloading from the upstream.
28
29 Some request / responses headers are preserved:
30
31 * request: `#{inspect(@keep_req_headers)}`
32 * response: `#{inspect(@keep_resp_headers)}`
33
34 If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be
35 set to `#{inspect(@default_cache_control_header)}`.
36
37 Options:
38
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, ….
42
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.
45
46 * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
47 read from the remote upstream.
48
49 * `inline_content_types`:
50 * `true` will not alter `content-disposition` (up to the upstream),
51 * `false` will add `content-disposition: attachment` to any request,
52 * a list of whitelisted content types
53
54 * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
55 doing content transformation (encoding, …) depending on the request.
56
57 * `req_headers`, `resp_headers` additional headers.
58
59 * `http`: options for [hackney](https://github.com/benoitc/hackney).
60
61 """
62 @hackney Application.get_env(:pleroma, :hackney, :hackney)
63 @httpoison Application.get_env(:pleroma, :httpoison, HTTPoison)
64
65 @default_hackney_options []
66
67 @inline_content_types [
68 "image/gif",
69 "image/jpeg",
70 "image/jpg",
71 "image/png",
72 "image/svg+xml",
73 "audio/mpeg",
74 "audio/mp3",
75 "video/webm",
76 "video/mp4",
77 "video/quicktime"
78 ]
79
80 require Logger
81 import Plug.Conn
82
83 @type option() ::
84 {:keep_user_agent, boolean}
85 | {:max_read_duration, :timer.time() | :infinity}
86 | {:max_body_length, non_neg_integer() | :infinity}
87 | {:http, []}
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()}
92
93 @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
94 def call(_conn, _url, _opts \\ [])
95
96 def call(conn = %{method: method}, url, opts) when method in @methods do
97 hackney_opts =
98 @default_hackney_options
99 |> Keyword.merge(Keyword.get(opts, :http, []))
100 |> @httpoison.process_request_options()
101
102 req_headers = build_req_headers(conn.req_headers, opts)
103
104 opts =
105 if filename = Pleroma.Web.MediaProxy.filename(url) do
106 Keyword.put_new(opts, :attachment_name, filename)
107 else
108 opts
109 end
110
111 with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
112 :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
113 response(conn, client, url, code, headers, opts)
114 else
115 {:ok, code, headers} ->
116 head_response(conn, url, code, headers, opts)
117 |> halt()
118
119 {:error, {:invalid_http_response, code}} ->
120 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
121
122 conn
123 |> error_or_redirect(
124 url,
125 code,
126 "Request failed: " <> Plug.Conn.Status.reason_phrase(code),
127 opts
128 )
129 |> halt()
130
131 {:error, error} ->
132 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
133
134 conn
135 |> error_or_redirect(url, 500, "Request failed", opts)
136 |> halt()
137 end
138 end
139
140 def call(conn, _, _) do
141 conn
142 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
143 |> halt()
144 end
145
146 defp request(method, url, headers, hackney_opts) do
147 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
148 method = method |> String.downcase() |> String.to_existing_atom()
149
150 case @hackney.request(method, url, headers, "", hackney_opts) do
151 {:ok, code, headers, client} when code in @valid_resp_codes ->
152 {:ok, code, downcase_headers(headers), client}
153
154 {:ok, code, headers} when code in @valid_resp_codes ->
155 {:ok, code, downcase_headers(headers)}
156
157 {:ok, code, _, _} ->
158 {:error, {:invalid_http_response, code}}
159
160 {:error, error} ->
161 {:error, error}
162 end
163 end
164
165 defp response(conn, client, url, status, headers, opts) do
166 result =
167 conn
168 |> put_resp_headers(build_resp_headers(headers, opts))
169 |> send_chunked(status)
170 |> chunk_reply(client, opts)
171
172 case result do
173 {:ok, conn} ->
174 halt(conn)
175
176 {:error, :closed, conn} ->
177 :hackney.close(client)
178 halt(conn)
179
180 {:error, error, conn} ->
181 Logger.warn(
182 "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
183 )
184
185 :hackney.close(client)
186 halt(conn)
187 end
188 end
189
190 defp chunk_reply(conn, client, opts) do
191 chunk_reply(conn, client, opts, 0, 0)
192 end
193
194 defp chunk_reply(conn, client, opts, sent_so_far, duration) do
195 with {:ok, duration} <-
196 check_read_duration(
197 duration,
198 Keyword.get(opts, :max_read_duration, @max_read_duration)
199 ),
200 {:ok, data} <- @hackney.stream_body(client),
201 {:ok, duration} <- increase_read_duration(duration),
202 sent_so_far = sent_so_far + byte_size(data),
203 :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
204 {:ok, conn} <- chunk(conn, data) do
205 chunk_reply(conn, client, opts, sent_so_far, duration)
206 else
207 :done -> {:ok, conn}
208 {:error, error} -> {:error, error, conn}
209 end
210 end
211
212 defp head_response(conn, _url, code, headers, opts) do
213 conn
214 |> put_resp_headers(build_resp_headers(headers, opts))
215 |> send_resp(code, "")
216 end
217
218 defp error_or_redirect(conn, url, code, body, opts) do
219 if Keyword.get(opts, :redirect_on_failure, false) do
220 conn
221 |> Phoenix.Controller.redirect(external: url)
222 |> halt()
223 else
224 conn
225 |> send_resp(code, body)
226 |> halt
227 end
228 end
229
230 defp downcase_headers(headers) do
231 Enum.map(headers, fn {k, v} ->
232 {String.downcase(k), v}
233 end)
234 end
235
236 defp get_content_type(headers) do
237 {_, content_type} =
238 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
239
240 [content_type | _] = String.split(content_type, ";")
241 content_type
242 end
243
244 defp put_resp_headers(conn, headers) do
245 Enum.reduce(headers, conn, fn {k, v}, conn ->
246 put_resp_header(conn, k, v)
247 end)
248 end
249
250 defp build_req_headers(headers, opts) do
251 headers
252 |> downcase_headers()
253 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
254 |> (fn headers ->
255 headers = headers ++ Keyword.get(opts, :req_headers, [])
256
257 if Keyword.get(opts, :keep_user_agent, false) do
258 List.keystore(
259 headers,
260 "user-agent",
261 0,
262 {"user-agent", Pleroma.Application.user_agent()}
263 )
264 else
265 headers
266 end
267 end).()
268 end
269
270 defp build_resp_headers(headers, opts) do
271 headers
272 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
273 |> build_resp_cache_headers(opts)
274 |> build_resp_content_disposition_header(opts)
275 |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
276 end
277
278 defp build_resp_cache_headers(headers, _opts) do
279 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
280 has_cache_control? = List.keymember?(headers, "cache-control", 0)
281
282 cond do
283 has_cache? && has_cache_control? ->
284 headers
285
286 has_cache? ->
287 # There's caching header present but no cache-control -- we need to explicitely override it
288 # to public as Plug defaults to "max-age=0, private, must-revalidate"
289 List.keystore(headers, "cache-control", 0, {"cache-control", "public"})
290
291 true ->
292 List.keystore(
293 headers,
294 "cache-control",
295 0,
296 {"cache-control", @default_cache_control_header}
297 )
298 end
299 end
300
301 defp build_resp_content_disposition_header(headers, opts) do
302 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
303
304 content_type = get_content_type(headers)
305
306 attachment? =
307 cond do
308 is_list(opt) && !Enum.member?(opt, content_type) -> true
309 opt == false -> true
310 true -> false
311 end
312
313 if attachment? do
314 disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment")
315 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
316 else
317 headers
318 end
319 end
320
321 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
322 with {_, size} <- List.keyfind(headers, "content-length", 0),
323 {size, _} <- Integer.parse(size),
324 true <- size <= limit do
325 :ok
326 else
327 false ->
328 {:error, :body_too_large}
329
330 _ ->
331 :ok
332 end
333 end
334
335 defp header_length_constraint(_, _), do: :ok
336
337 defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
338 {:error, :body_too_large}
339 end
340
341 defp body_size_constraint(_, _), do: :ok
342
343 defp check_read_duration(duration, max)
344 when is_integer(duration) and is_integer(max) and max > 0 do
345 if duration > max do
346 {:error, :read_duration_exceeded}
347 else
348 {:ok, {duration, :erlang.system_time(:millisecond)}}
349 end
350 end
351
352 defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
353
354 defp increase_read_duration({previous_duration, started})
355 when is_integer(previous_duration) and is_integer(started) do
356 duration = :erlang.system_time(:millisecond) - started
357 {:ok, previous_duration + duration}
358 end
359
360 defp increase_read_duration(_) do
361 {:ok, :no_duration_limit, :no_duration_limit}
362 end
363 end