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

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7933
This commit is contained in:
lain 2026-07-25 05:36:16 +00:00
commit 4fe567064a
7 changed files with 280 additions and 19 deletions

View file

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

View file

@ -17,6 +17,7 @@ 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], [])
@ -59,7 +60,12 @@ 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 * a list of whitelisted content types for which `content-disposition: inline`
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.
@ -69,11 +75,15 @@ defmodule Pleroma.ReverseProxy do
@default_options [pool: :media] @default_options [pool: :media]
@inline_content_types [ @inline_content_types [
"image/apng",
"image/avif",
"image/bmp",
"image/gif", "image/gif",
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/svg+xml", "image/svg+xml",
"image/webp",
"audio/mpeg", "audio/mpeg",
"audio/mp3", "audio/mp3",
"video/webm", "video/webm",
@ -92,6 +102,7 @@ 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()
@ -184,12 +195,14 @@ 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)}")
{headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
result = result =
conn conn
|> put_resp_headers(build_resp_headers(headers, opts)) |> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat |> streaming_compat
|> send_chunked(status) |> send_chunked(status)
|> chunk_reply(client, opts) |> chunk_reply(client, opts, prefetched)
case result do case result do
{:ok, conn} -> {:ok, conn} ->
@ -209,18 +222,28 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp chunk_reply(conn, client, opts) do defp chunk_reply(conn, client, opts, :none) do
chunk_reply(conn, client, opts, 0, 0) chunk_reply(conn, client, opts, 0, 0)
end end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
with {:ok, duration} <- defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
check_read_duration(
duration, defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
Keyword.get(opts, :max_read_duration, @max_read_duration) with :ok <-
body_size_constraint(
byte_size(data),
Keyword.get(opts, :max_body_length, @max_body_length)
), ),
{:ok, data, client} <- client().stream_body(client), {:ok, conn} <- chunk(conn, data) do
{:ok, duration} <- increase_read_duration(duration), chunk_reply(conn, client, opts, byte_size(data), duration)
else
{:error, error} -> {:error, error, conn}
end
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, data, client, duration} <- read_chunk(client, duration, opts),
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(
@ -235,6 +258,66 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp read_chunk(client, duration, opts) do
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
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)}")
@ -281,17 +364,34 @@ defmodule Pleroma.ReverseProxy do
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end) |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> 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)
|> Keyword.merge(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)
if range? && List.keymember?(headers, "accept-encoding", 0) do cond do
List.keydelete(headers, "accept-encoding", 0) range? && List.keymember?(headers, "accept-encoding", 0) ->
else List.keydelete(headers, "accept-encoding", 0)
headers
true ->
headers
end end
end end
@ -384,9 +484,33 @@ defmodule Pleroma.ReverseProxy do
disposition = "attachment; filename=\"#{name}\"" disposition = "attachment; filename=\"#{name}\""
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) replace_header(headers, "content-disposition", disposition)
else else
headers if opt == true do
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
defp replace_header(headers, key, value) do
[{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)]
end
defp inline_filename(content_type) do
case MIME.extensions(content_type) do
[ext | _] when ext != "" -> "inline.#{ext}"
_ -> nil
end end
end end
@ -421,7 +545,9 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} defp check_read_duration(_, _), do: {:ok, :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

@ -620,6 +620,7 @@ 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,6 +208,7 @@ 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

@ -222,6 +222,56 @@ 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 "preserves a generic content type for a non-image body", %{conn: conn} do
body = "not an image"
ClientMock
|> expect(:request, fn :get, "/extensionless", _, _, _ ->
{:ok, 200, [], %{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)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"attachment; filename=\"extensionless\""
]
end
defp headers_mock(_) do defp headers_mock(_) do
ClientMock ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ -> |> expect(:request, fn :get, "/headers", headers, _, _ ->
@ -328,6 +378,24 @@ 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
@ -340,6 +408,50 @@ 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,6 +668,23 @@ 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,7 +71,10 @@ 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 -> %Conn{status: :success} end do call: fn _conn, _url, opts ->
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