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