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