Update translation files
[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 |> put_private(:proxied_url, url)
118 |> error_or_redirect(500, "Request failed", opts)
119 |> halt()
120
121 {:ok, status, headers} ->
122 conn
123 |> put_private(:proxied_url, url)
124 |> head_response(status, headers, opts)
125 |> halt()
126
127 {:error, {:invalid_http_response, status}} ->
128 Logger.error(
129 "#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{status}"
130 )
131
132 track_failed_url(url, status, opts)
133
134 conn
135 |> put_private(:proxied_url, url)
136 |> error_or_redirect(
137 status,
138 "Request failed: " <> Plug.Conn.Status.reason_phrase(status),
139 opts
140 )
141 |> halt()
142
143 {:error, error} ->
144 Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
145 track_failed_url(url, error, opts)
146
147 conn
148 |> put_private(:proxied_url, url)
149 |> error_or_redirect(500, "Request failed", opts)
150 |> halt()
151 end
152 end
153
154 def call(conn, _, _) do
155 conn
156 |> send_resp(400, Plug.Conn.Status.reason_phrase(400))
157 |> halt()
158 end
159
160 defp request(method, url, headers, opts) do
161 Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
162 method = method |> String.downcase() |> String.to_existing_atom()
163
164 opts = opts ++ [receive_timeout: @max_read_duration]
165
166 case Pleroma.HTTP.request(method, url, "", headers, opts) do
167 {:ok, %Tesla.Env{status: status, headers: headers, body: body}}
168 when status in @valid_resp_codes ->
169 {:ok, status, downcase_headers(headers), body}
170
171 {:ok, %Tesla.Env{status: status, headers: headers}} when status in @valid_resp_codes ->
172 {:ok, status, downcase_headers(headers)}
173
174 {:ok, %Tesla.Env{status: status}} ->
175 {:error, {:invalid_http_response, status}}
176
177 {:error, error} ->
178 {:error, error}
179 end
180 end
181
182 defp response(conn, body, status, headers, opts) do
183 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
184
185 conn
186 |> put_resp_headers(build_resp_headers(headers, opts))
187 |> send_resp(status, body)
188 end
189
190 defp head_response(conn, status, headers, opts) do
191 Logger.debug("#{__MODULE__} #{status} #{conn.private[:proxied_url]} #{inspect(headers)}")
192
193 conn
194 |> put_resp_headers(build_resp_headers(headers, opts))
195 |> send_resp(status, "")
196 end
197
198 defp error_or_redirect(conn, status, body, opts) do
199 if Keyword.get(opts, :redirect_on_failure, false) do
200 conn
201 |> Phoenix.Controller.redirect(external: conn.private[:proxied_url])
202 |> halt()
203 else
204 conn
205 |> send_resp(status, body)
206 |> halt
207 end
208 end
209
210 defp downcase_headers(headers) do
211 Enum.map(headers, fn {k, v} ->
212 {String.downcase(k), v}
213 end)
214 end
215
216 defp get_content_type(headers) do
217 {_, content_type} =
218 List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
219
220 [content_type | _] = String.split(content_type, ";")
221 content_type
222 end
223
224 defp put_resp_headers(conn, headers) do
225 Enum.reduce(headers, conn, fn {k, v}, conn ->
226 put_resp_header(conn, k, v)
227 end)
228 end
229
230 defp build_req_headers(headers, opts) do
231 headers
232 |> downcase_headers()
233 |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
234 |> build_req_range_or_encoding_header(opts)
235 |> Keyword.merge(Keyword.get(opts, :req_headers, []))
236 end
237
238 # Disable content-encoding if any @range_headers are requested (see #1823).
239 defp build_req_range_or_encoding_header(headers, _opts) do
240 range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
241
242 if range? && List.keymember?(headers, "accept-encoding", 0) do
243 List.keydelete(headers, "accept-encoding", 0)
244 else
245 headers
246 end
247 end
248
249 defp build_resp_headers(headers, opts) do
250 headers
251 |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
252 |> build_resp_cache_headers(opts)
253 |> build_resp_content_disposition_header(opts)
254 |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
255 end
256
257 defp build_resp_cache_headers(headers, _opts) do
258 has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
259
260 cond do
261 has_cache? ->
262 # There's caching header present but no cache-control -- we need to set our own
263 # as Plug defaults to "max-age=0, private, must-revalidate"
264 List.keystore(
265 headers,
266 "cache-control",
267 0,
268 {"cache-control", @default_cache_control_header}
269 )
270
271 true ->
272 List.keystore(
273 headers,
274 "cache-control",
275 0,
276 {"cache-control", @default_cache_control_header}
277 )
278 end
279 end
280
281 defp build_resp_content_disposition_header(headers, opts) do
282 opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
283
284 content_type = get_content_type(headers)
285
286 attachment? =
287 cond do
288 is_list(opt) && !Enum.member?(opt, content_type) -> true
289 opt == false -> true
290 true -> false
291 end
292
293 if attachment? do
294 name =
295 try do
296 {{"content-disposition", content_disposition_string}, _} =
297 List.keytake(headers, "content-disposition", 0)
298
299 [name | _] =
300 Regex.run(
301 ~r/filename="((?:[^"\\]|\\.)*)"/u,
302 content_disposition_string || "",
303 capture: :all_but_first
304 )
305
306 name
307 rescue
308 MatchError -> Keyword.get(opts, :attachment_name, "attachment")
309 end
310
311 disposition = "attachment; filename=\"#{name}\""
312
313 List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
314 else
315 headers
316 end
317 end
318
319 defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
320 with {_, size} <- List.keyfind(headers, "content-length", 0),
321 {size, _} <- Integer.parse(size),
322 true <- size <= limit do
323 :ok
324 else
325 false ->
326 {:error, :body_too_large}
327
328 _ ->
329 :ok
330 end
331 end
332
333 defp header_length_constraint(_, _), do: :ok
334
335 defp track_failed_url(url, error, opts) do
336 ttl =
337 unless error in [:body_too_large, 400, 204] do
338 Keyword.get(opts, :failed_request_ttl, @failed_request_ttl)
339 else
340 nil
341 end
342
343 @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)
344 end
345 end