Revert "Merge pull request 'MediaProxy: detect images when there's no file extension' (#7933) from mediaproxy-content-type into develop"

A partial revert, keeping the updated @inline_content_types list and
cosmetic changes.

This reverts commit 4fe567064a, reversing
changes made to 9b8aa2e2ee.
This commit is contained in:
Phantasm 2026-08-13 18:11:18 +02:00
commit f672ab337c
No known key found for this signature in database
GPG key ID: 2669E588BCC634C8
7 changed files with 23 additions and 294 deletions

View file

@ -1 +0,0 @@
Detect image content types for extensionless MediaProxy responses.

View file

@ -17,7 +17,6 @@ defmodule Pleroma.ReverseProxy do
@max_read_duration :timer.seconds(30) @max_read_duration :timer.seconds(30)
@max_body_length :infinity @max_body_length :infinity
@failed_request_ttl :timer.seconds(60) @failed_request_ttl :timer.seconds(60)
@sniff_bytes 8 * 1024
@methods ~w(GET HEAD) @methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], []) @allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@ -70,12 +69,7 @@ defmodule Pleroma.ReverseProxy do
* `inline_content_types`: * `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream), * `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request, * `false` will add `content-disposition: attachment` to any request,
* a list of whitelisted content types for which `content-disposition: inline` * a list of whitelisted content types
is always set (overriding any upstream header) so the media can be embedded
in pages; the filename is derived from the content type
* `sniff_content_type` (default `false`): detects an image MIME type from the first
response chunk when the upstream type is missing or `application/octet-stream`.
* `req_headers`, `resp_headers` additional headers. * `req_headers`, `resp_headers` additional headers.
@ -112,7 +106,6 @@ defmodule Pleroma.ReverseProxy do
| {:req_headers, [{String.t(), String.t()}]} | {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())} | {:inline_content_types, boolean() | list(String.t())}
| {:sniff_content_type, boolean()}
| {:redirect_on_failure, boolean()} | {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t() @spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
@ -228,30 +221,11 @@ defmodule Pleroma.ReverseProxy do
defp response(conn, client, url, status, headers, opts) do defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
case maybe_prefetch_for_content_type(headers, client, opts) do
{_headers, client, {:error, {:upstream, error}}} ->
Logger.warning(
"#{__MODULE__} request to #{url} failed before streaming: #{inspect(error)}"
)
client().close(client)
track_failed_url(url, error, opts)
conn
|> error_or_redirect(url, 500, "Request failed", opts)
|> halt()
{headers, client, prefetched} ->
stream_response(conn, client, url, status, headers, opts, prefetched)
end
end
defp stream_response(conn, client, url, status, headers, opts, prefetched) do
result = result =
conn conn
|> put_resp_headers(build_resp_headers(headers, opts)) |> put_resp_headers(build_resp_headers(headers, opts))
|> send_chunked(status) |> send_chunked(status)
|> chunk_reply(client, opts, prefetched) |> chunk_reply(client, opts)
case result do case result do
{:ok, conn} -> {:ok, conn} ->
@ -272,29 +246,18 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp chunk_reply(conn, client, opts, :none) do defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0) chunk_reply(conn, client, opts, 0, 0)
end end
defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
with :ok <-
body_size_constraint(
byte_size(data),
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- write_chunk(conn, data) do
chunk_reply(conn, client, opts, byte_size(data), duration)
else
{:error, {:downstream, error}} -> {:error, {:downstream, error}, conn}
{:error, error} -> {:error, {:upstream, error}, conn}
end
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, data, client, duration} <- read_chunk(client, duration, opts), with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data), sent_so_far = sent_so_far + byte_size(data),
:ok <- :ok <-
body_size_constraint( body_size_constraint(
@ -310,24 +273,6 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp read_chunk(client, duration, opts) do
result =
with {:ok, timer} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(timer) do
{:ok, data, client, duration}
end
case result do
{:error, error} -> {:error, {:upstream, error}}
result -> result
end
end
defp write_chunk(conn, data) do defp write_chunk(conn, data) do
case chunk(conn, data) do case chunk(conn, data) do
{:error, error} -> {:error, {:downstream, error}} {:error, error} -> {:error, {:downstream, error}}
@ -335,54 +280,6 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp maybe_prefetch_for_content_type(headers, client, opts) do
if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do
case read_chunk(client, 0, opts) do
{:ok, data, client, duration} ->
{maybe_put_image_content_type(headers, data), client, {:ok, data, duration}}
:done ->
{headers, client, :done}
{:error, error} ->
{headers, client, {:error, error}}
end
else
{headers, client, :none}
end
end
defp generic_content_type?(headers) do
headers
|> get_content_type()
|> String.trim()
|> String.downcase()
|> then(&(&1 in ["", "application/octet-stream"]))
end
defp maybe_put_image_content_type(headers, data) do
with false <- data == "",
{:ok, %{mime_type: "image/" <> _ = content_type}} <-
Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))},
pool: Pleroma.MajicPool
) do
[
{"content-type", content_type}
| Enum.reject(headers, fn {key, _} -> key == "content-type" end)
]
else
_ -> headers
end
rescue
error ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}")
headers
catch
kind, reason ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}")
headers
end
defp head_response(conn, url, code, headers, opts) do defp head_response(conn, url, code, headers, opts) do
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
@ -430,32 +327,15 @@ defmodule Pleroma.ReverseProxy do
|> build_req_range_or_encoding_header(opts) |> build_req_range_or_encoding_header(opts)
|> build_req_user_agent_header(opts) |> build_req_user_agent_header(opts)
|> merge_headers(Keyword.get(opts, :req_headers, [])) |> merge_headers(Keyword.get(opts, :req_headers, []))
|> maybe_force_identity_encoding(opts)
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp maybe_force_identity_encoding(headers, opts) do
if Keyword.get(opts, :sniff_content_type, false) do
List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"})
else
headers
end
end end
# Disable content-encoding if any @range_headers are requested (see #1823). # Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end) range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
cond do if range? && List.keymember?(headers, "accept-encoding", 0) do
range? && List.keymember?(headers, "accept-encoding", 0) ->
List.keydelete(headers, "accept-encoding", 0) List.keydelete(headers, "accept-encoding", 0)
else
true ->
headers headers
end end
end end
@ -479,6 +359,12 @@ defmodule Pleroma.ReverseProxy do
|> ensure_no_transform() |> ensure_no_transform()
end end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp ensure_no_transform(headers) do defp ensure_no_transform(headers) do
{_, cache_control} = {_, cache_control} =
List.keyfind(headers, "cache-control", 0, {"cache-control", @default_cache_control_header}) List.keyfind(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
@ -568,20 +454,7 @@ defmodule Pleroma.ReverseProxy do
replace_header(headers, "content-disposition", disposition) replace_header(headers, "content-disposition", disposition)
else else
if opt == true do
headers headers
else
name = inline_filename(content_type)
disposition =
if name do
"inline; filename=\"#{name}\""
else
"inline"
end
replace_header(headers, "content-disposition", disposition)
end
end end
end end
@ -589,13 +462,6 @@ defmodule Pleroma.ReverseProxy do
[{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)] [{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)]
end end
defp inline_filename(content_type) do
case MIME.extensions(content_type) do
[ext | _] when ext != "" -> "inline.#{ext}"
_ -> nil
end
end
defp header_length_constraint(headers, limit) do defp header_length_constraint(headers, limit) do
lengths = lengths =
for {"content-length", value} <- headers, for {"content-length", value} <- headers,
@ -640,9 +506,7 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit} defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
defp increase_read_duration(:no_duration_limit), do: {:ok, :no_duration_limit}
defp increase_read_duration({previous_duration, started}) defp increase_read_duration({previous_duration, started})
when is_integer(previous_duration) and is_integer(started) do when is_integer(previous_duration) and is_integer(started) do

View file

@ -631,7 +631,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
String.contains?(media_type, "image") -> "image" String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video" String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio" String.contains?(media_type, "audio") -> "audio"
attachment["type"] == "Image" -> "image"
true -> "unknown" true -> "unknown"
end end

View file

@ -208,7 +208,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
defp media_proxy_opts do defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], []) Config.get([:media_proxy, :proxy_opts], [])
|> Keyword.put_new(:sniff_content_type, true)
end end
defp sandbox(conn, _params) do defp sandbox(conn, _params) do

View file

@ -579,56 +579,6 @@ defmodule Pleroma.ReverseProxyTest do
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end end
test "sniffs and preserves an image body with a generic content type", %{conn: conn} do
body = File.read!("test/fixtures/image.jpg")
ClientMock
|> expect(:request, fn :get, "/extensionless", headers, _, _ ->
assert {"accept-encoding", "identity"} in headers
{:ok, 200, [{"content-type", "application/octet-stream"}], %{body: body}}
end)
|> expect(:stream_body, fn %{body: ^body} = client ->
{:ok, body, Map.delete(client, :body)}
end)
|> expect(:stream_body, fn %{} -> :done end)
conn =
ReverseProxy.call(conn, "/extensionless",
sniff_content_type: true,
max_read_duration: :infinity,
req_headers: [{"accept-encoding", "gzip"}]
)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["image/jpeg"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"inline; filename=\"inline.jpg\""
]
end
test "redirects a content sniffing failure before committing the response", %{conn: conn} do
url = "https://example.com/extensionless"
ClientMock
|> expect(:request, fn :get, ^url, _, _, _ ->
{:ok, 200, [{"content-type", "application/octet-stream"}], %{url: url}}
end)
|> expect(:stream_body, fn _ -> {:error, :closed} end)
|> expect(:close, fn _ -> :ok end)
conn =
ReverseProxy.call(conn, url,
sniff_content_type: true,
redirect_on_failure: true
)
assert conn.status == 302
assert Conn.get_resp_header(conn, "location") == [url]
assert conn.state == :sent
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
end
test "preserves a generic content type for a non-image body", %{conn: conn} do test "preserves a generic content type for a non-image body", %{conn: conn} do
body = "not an image" body = "not an image"
@ -823,24 +773,6 @@ defmodule Pleroma.ReverseProxyTest do
conn = ReverseProxy.call(conn, "/disposition") conn = ReverseProxy.call(conn, "/disposition")
assert {"content-type", "image/gif"} in conn.resp_headers assert {"content-type", "image/gif"} in conn.resp_headers
assert {"content-disposition", "inline; filename=\"inline.gif\""} in conn.resp_headers
end
test "forces inline for inline content types overriding upstream attachment", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"filename.png\""},
{"content-disposition", "attachment; filename=\"duplicate.png\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert String.starts_with?(disposition, "inline")
refute String.starts_with?(disposition, "attachment")
end end
test "with content-disposition header", %{conn: conn} do test "with content-disposition header", %{conn: conn} do
@ -853,50 +785,6 @@ defmodule Pleroma.ReverseProxyTest do
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
end end
test "with inline_content_types: true leaves upstream headers untouched", %{
conn: conn
} do
# opt == true: the proxy must not synthesise or rewrite content-disposition.
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"upstream.png\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers
end
test "forces bare inline for a whitelisted type with no MIME extension", %{
conn: conn
} do
# image/x-foo-bar has no entry in MIME's database, so inline_filename/1
# returns nil and the disposition should be the bare token "inline".
disposition_headers_mock([
{"content-type", "image/x-foo-bar"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/x-foo-bar"])
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert disposition == "inline"
end
test "serves modern browser image types inline", %{conn: conn} do
disposition_headers_mock([
{"content-type", "image/webp"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
assert Conn.get_resp_header(conn, "content-disposition") == [
"inline; filename=\"inline.webp\""
]
end
end end
describe "content-type sanitisation" do describe "content-type sanitisation" do

View file

@ -668,23 +668,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert expected == StatusView.render("attachment.json", %{attachment: object}) assert expected == StatusView.render("attachment.json", %{attachment: object})
assert_schema(expected, "Attachment", api_spec) assert_schema(expected, "Attachment", api_spec)
end end
test "uses the ActivityStreams image type when the MIME type is generic" do
object = %{
"type" => "Image",
"url" => [
%{
"mediaType" => "application/octet-stream",
"href" => "https://example.com/extensionless"
}
]
}
attachment = StatusView.render("attachment.json", %{attachment: object})
assert attachment.type == "image"
assert attachment.pleroma.mime_type == "application/octet-stream"
end
end end
test "put the url advertised in the Activity in to the url attribute" do test "put the url advertised in the Activity in to the url attribute" do

View file

@ -71,10 +71,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do
with_mock Pleroma.ReverseProxy, with_mock Pleroma.ReverseProxy,
call: fn _conn, _url, opts -> call: fn _conn, _url, _opts -> %Conn{status: :success} end do
assert opts[:sniff_content_type]
%Conn{status: :success}
end do
assert %Conn{status: :success} = get(conn, url) assert %Conn{status: :success} = get(conn, url)
end end
end end