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