diff --git a/changelog.d/mediaproxy-content-type.fix b/changelog.d/mediaproxy-content-type.fix
new file mode 100644
index 000000000..f36dd6283
--- /dev/null
+++ b/changelog.d/mediaproxy-content-type.fix
@@ -0,0 +1 @@
+Detect image content types for extensionless MediaProxy responses.
diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex
index 11f041c57..061b2de9b 100644
--- a/lib/pleroma/reverse_proxy.ex
+++ b/lib/pleroma/reverse_proxy.ex
@@ -17,6 +17,7 @@ defmodule Pleroma.ReverseProxy do
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@failed_request_ttl :timer.seconds(60)
+ @sniff_bytes 8 * 1024
@methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@@ -63,6 +64,9 @@ defmodule Pleroma.ReverseProxy do
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.
* `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun).
@@ -71,11 +75,15 @@ defmodule Pleroma.ReverseProxy do
@default_options [pool: :media]
@inline_content_types [
+ "image/apng",
+ "image/avif",
+ "image/bmp",
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
+ "image/webp",
"audio/mpeg",
"audio/mp3",
"video/webm",
@@ -94,6 +102,7 @@ defmodule Pleroma.ReverseProxy do
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())}
+ | {:sniff_content_type, boolean()}
| {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
@@ -186,12 +195,14 @@ defmodule Pleroma.ReverseProxy do
defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
+ {headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
+
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat
|> send_chunked(status)
- |> chunk_reply(client, opts)
+ |> chunk_reply(client, opts, prefetched)
case result do
{:ok, conn} ->
@@ -211,18 +222,28 @@ defmodule Pleroma.ReverseProxy do
end
end
- defp chunk_reply(conn, client, opts) do
+ defp chunk_reply(conn, client, opts, :none) do
chunk_reply(conn, client, opts, 0, 0)
end
- defp chunk_reply(conn, client, opts, sent_so_far, duration) do
- with {:ok, duration} <-
- check_read_duration(
- duration,
- Keyword.get(opts, :max_read_duration, @max_read_duration)
+ 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, data, client} <- client().stream_body(client),
- {:ok, duration} <- increase_read_duration(duration),
+ {:ok, conn} <- chunk(conn, data) do
+ 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),
:ok <-
body_size_constraint(
@@ -237,6 +258,66 @@ defmodule Pleroma.ReverseProxy do
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
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
@@ -283,17 +364,34 @@ defmodule Pleroma.ReverseProxy do
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> build_req_range_or_encoding_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
# Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
- if range? && List.keymember?(headers, "accept-encoding", 0) do
- List.keydelete(headers, "accept-encoding", 0)
- else
- headers
+ cond do
+ range? && List.keymember?(headers, "accept-encoding", 0) ->
+ List.keydelete(headers, "accept-encoding", 0)
+
+ true ->
+ headers
end
end
@@ -386,7 +484,7 @@ defmodule Pleroma.ReverseProxy do
disposition = "attachment; filename=\"#{name}\""
- List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
+ replace_header(headers, "content-disposition", disposition)
else
if opt == true do
headers
@@ -400,11 +498,15 @@ defmodule Pleroma.ReverseProxy do
"inline"
end
- List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
+ 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}"
@@ -443,7 +545,9 @@ defmodule Pleroma.ReverseProxy do
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})
when is_integer(previous_duration) and is_integer(started) do
diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex
deleted file mode 100644
index 19215b627..000000000
--- a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex
+++ /dev/null
@@ -1,59 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2022 Pleroma Authors
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer do
- @moduledoc """
- Best-effort MIME sniffing for remote attachments whose declared type is
- unusable (missing or `application/octet-stream`).
-
- Fetches the first chunk of the URL and runs it through libmagic. Only
- `image/*` results are returned; anything else, or any failure, yields `nil` —
- so this never reclassifies non-image media and never blocks ingestion. This
- exists because some remotes (e.g. extensionless Cloudflare Images URLs) ship
- attachments with no usable `mediaType`, which would otherwise render in the
- timeline as a clickable link instead of an inline image.
- """
-
- alias Pleroma.HTTP
-
- require Logger
-
- # libmagic only needs the file header; a few KB is plenty and keeps the
- # request cheap even when the origin ignores Range.
- @sniff_bytes 8 * 1024
- @timeout 5_000
-
- @spec sniff_image_type(binary() | nil) :: {:ok, binary() | nil}
- def sniff_image_type(url) when is_binary(url) and url != "" do
- do_sniff(url)
- rescue
- e ->
- Logger.debug(
- "Attachment type sniff failed for #{url}: #{Exception.format(:error, e, __STACKTRACE__)}"
- )
-
- {:ok, nil}
- end
-
- def sniff_image_type(_), do: {:ok, nil}
-
- defp do_sniff(url) do
- headers = [{"range", "bytes=0-#{@sniff_bytes - 1}"}]
- opts = [pool: :media, timeout: @timeout, recv_timeout: @timeout]
-
- with {:ok, %Tesla.Env{status: status, body: body}} when status in 200..299 <-
- HTTP.get(url, headers, opts),
- false <- blank?(body),
- {:ok, %{mime_type: mime}} <- Majic.perform({:bytes, body}, pool: Pleroma.MajicPool),
- true <- String.starts_with?(mime, "image/") do
- {:ok, mime}
- else
- _ -> {:ok, nil}
- end
- end
-
- defp blank?(nil), do: true
- defp blank?(""), do: true
- defp blank?(_), do: false
-end
diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
index b6e30a438..5ee9e7549 100644
--- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
@@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators
- alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
import Ecto.Changeset
@@ -62,27 +61,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
end
def fix_media_type(data) do
- current = data["mediaType"] || data["mimeType"] || "application/octet-stream"
-
- resolved =
- if sniffable?(current) do
- case AttachmentTypeSniffer.sniff_image_type(data["href"]) do
- {:ok, mime} when is_binary(mime) -> mime
- _ -> current
- end
- else
- current
- end
-
- Map.put(data, "mediaType", resolved)
+ Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream")
end
- # Only sniff when the declared type carries no real information. Everything
- # else (including unrecognized-but-present types) is left as the remote sent it.
- defp sniffable?("application/octet-stream"), do: true
- defp sniffable?(""), do: true
- defp sniffable?(_), do: false
-
defp handle_href(href, mediaType, data) do
[
%{
diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex
index 6949c5f2d..725dc2ce7 100644
--- a/lib/pleroma/web/mastodon_api/views/status_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -620,6 +620,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio"
+ attachment["type"] == "Image" -> "image"
true -> "unknown"
end
diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex
index a0aafc32e..93f692008 100644
--- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex
+++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex
@@ -208,6 +208,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], [])
+ |> Keyword.put_new(:sniff_content_type, true)
end
defp sandbox(conn, _params) do
diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs
index 5f75f2a07..ad1d75f33 100644
--- a/test/pleroma/reverse_proxy_test.exs
+++ b/test/pleroma/reverse_proxy_test.exs
@@ -222,6 +222,56 @@ defmodule Pleroma.ReverseProxyTest do
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
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
ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ ->
@@ -298,21 +348,21 @@ defmodule Pleroma.ReverseProxyTest do
end
end
- defp disposition_headers_mock(headers, url \\ "/disposition") do
+ defp disposition_headers_mock(headers) do
ClientMock
- |> expect(:request, fn :get, ^url, _, _, _ ->
- Registry.register(ClientMock, url, 0)
+ |> expect(:request, fn :get, "/disposition", _, _, _ ->
+ Registry.register(ClientMock, "/disposition", 0)
- {:ok, 200, headers, %{url: url}}
+ {:ok, 200, headers, %{url: "/disposition"}}
end)
- |> expect(:stream_body, 2, fn %{url: ^url} = client ->
- case Registry.lookup(ClientMock, url) do
+ |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
+ case Registry.lookup(ClientMock, "/disposition") do
[{_, 0}] ->
- Registry.update_value(ClientMock, url, &(&1 + 1))
+ Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
{:ok, "", client}
[{_, 1}] ->
- Registry.unregister(ClientMock, url)
+ Registry.unregister(ClientMock, "/disposition")
:done
end
end)
@@ -337,6 +387,7 @@ defmodule Pleroma.ReverseProxyTest do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"filename.png\""},
+ {"content-disposition", "attachment; filename=\"duplicate.png\""},
{"content-length", "0"}
])
@@ -347,23 +398,6 @@ defmodule Pleroma.ReverseProxyTest do
refute String.starts_with?(disposition, "attachment")
end
- test "forces inline based on content type even without a file extension in the url", %{
- conn: conn
- } do
- disposition_headers_mock(
- [
- {"content-type", "image/jpeg"},
- {"content-length", "0"}
- ],
- "/original"
- )
-
- conn = ReverseProxy.call(conn, "/original")
-
- [disposition] = Conn.get_resp_header(conn, "content-disposition")
- assert disposition == "inline; filename=\"inline.jpg\""
- end
-
test "with content-disposition header", %{conn: conn} do
disposition_headers_mock([
{"content-disposition", "attachment; filename=\"filename.jpg\""},
@@ -390,69 +424,6 @@ defmodule Pleroma.ReverseProxyTest do
assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers
end
- test "with inline_content_types: true does not synthesise inline when upstream is absent", %{
- conn: conn
- } do
- disposition_headers_mock([
- {"content-type", "image/png"},
- {"content-length", "0"}
- ])
-
- conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
-
- assert Conn.get_resp_header(conn, "content-disposition") == []
- end
-
- test "with inline_content_types: false forces attachment for everything", %{
- conn: conn
- } do
- disposition_headers_mock([
- {"content-type", "image/png"},
- {"content-disposition", "inline; filename=\"pic.png\""},
- {"content-length", "0"}
- ])
-
- conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
-
- [disposition] = Conn.get_resp_header(conn, "content-disposition")
- assert String.starts_with?(disposition, "attachment")
- end
-
- test "with inline_content_types: false derives attachment filename from the URL basename when no upstream filename", %{
- conn: conn
- } do
- # No content-disposition header: the attachment branch falls back to
- # attachment_name, which by default comes from MediaProxy.filename/1
- # (the URL basename).
- disposition_headers_mock([
- {"content-type", "image/png"},
- {"content-length", "0"}
- ])
-
- conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
-
- [disposition] = Conn.get_resp_header(conn, "content-disposition")
- assert disposition == "attachment; filename=\"disposition\""
- end
-
- test "with inline_content_types: false honours an explicit attachment_name opt", %{
- conn: conn
- } do
- disposition_headers_mock([
- {"content-type", "image/png"},
- {"content-length", "0"}
- ])
-
- conn =
- ReverseProxy.call(conn, "/disposition",
- inline_content_types: false,
- attachment_name: "custom.bin"
- )
-
- [disposition] = Conn.get_resp_header(conn, "content-disposition")
- assert disposition == "attachment; filename=\"custom.bin\""
- end
-
test "forces bare inline for a whitelisted type with no MIME extension", %{
conn: conn
} do
@@ -469,36 +440,17 @@ defmodule Pleroma.ReverseProxyTest do
assert disposition == "inline"
end
- test "honours a custom inline_content_types whitelist", %{conn: conn} do
- # image/bmp is NOT in the default whitelist; with a custom whitelist
- # that includes it, the proxy should force inline.
+ test "serves modern browser image types inline", %{conn: conn} do
disposition_headers_mock([
- {"content-type", "image/bmp"},
- {"content-disposition", "attachment; filename=\"upstream.bmp\""},
+ {"content-type", "image/webp"},
{"content-length", "0"}
])
- conn =
- ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
+ conn = ReverseProxy.call(conn, "/disposition")
- [disposition] = Conn.get_resp_header(conn, "content-disposition")
- assert String.starts_with?(disposition, "inline")
- assert String.ends_with?(disposition, "inline.bmp\"")
- end
-
- test "treats a type outside the whitelist as attachment even on custom whitelist", %{
- conn: conn
- } do
- disposition_headers_mock([
- {"content-type", "image/png"},
- {"content-disposition", "attachment; filename=\"filename.png\""},
- {"content-length", "0"}
- ])
-
- conn =
- ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
-
- assert {"content-disposition", "attachment; filename=\"filename.png\""} in conn.resp_headers
+ assert Conn.get_resp_header(conn, "content-disposition") == [
+ "inline; filename=\"inline.webp\""
+ ]
end
end
diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs
deleted file mode 100644
index 52b1b67a1..000000000
--- a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs
+++ /dev/null
@@ -1,178 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2022 Pleroma Authors
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSnifferTest do
- use Pleroma.DataCase, async: true
-
- alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
-
- # @sniff_bytes must mirror the value in the module under test; we assert the
- # Range header that the sniffer sends, so the test should fail loudly if the
- # constant drifts.
- @sniff_bytes 8 * 1024
- @range_header "bytes=0-#{@sniff_bytes - 1}"
-
- describe "sniff_image_type/1 with a real image body" do
- test "returns image/jpeg for a JPEG body" do
- url = "https://example.com/media/image.jpg"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: jpeg}
- end)
-
- assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns image/png for a PNG body" do
- url = "https://example.com/media/image.png"
- png = File.read!("test/fixtures/image.png")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: png}
- end)
-
- assert {:ok, "image/png"} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns image/gif for a GIF body" do
- url = "https://example.com/media/image.gif"
- gif = File.read!("test/fixtures/image.gif")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: gif}
- end)
-
- assert {:ok, "image/gif"} = AttachmentTypeSniffer.sniff_image_type(url)
- end
- end
-
- describe "sniff_image_type/1 sends a ranged request" do
- test "sends a Range header asking for the first chunk" do
- url = "https://example.com/media/ranged"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn
- %{method: :get, url: ^url, headers: headers} ->
- headers_map = Enum.into(headers, %{})
- assert headers_map["range"] == @range_header
-
- %Tesla.Env{status: 200, body: jpeg}
-
- %{method: :get, url: ^url} ->
- # If the sniffer did not send the Range header, fail explicitly.
- flunk("expected Range header to be sent with the request")
- end)
-
- assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
- end
- end
-
- describe "sniff_image_type/1 with a non-image body" do
- test "returns nil for plain text" do
- url = "https://example.com/media/note.txt"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil for an HTML body" do
- url = "https://example.com/page"
- html = File.read!("test/fixtures/rel_me_anchor.html")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: html}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil for an empty body" do
- url = "https://example.com/media/empty"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: ""}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil for a nil body" do
- url = "https://example.com/media/nil-body"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: nil}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
- end
-
- describe "sniff_image_type/1 on HTTP failure" do
- test "returns nil on a 404" do
- url = "https://example.com/media/missing"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 404, body: ""}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil on a 500" do
- url = "https://example.com/media/oops"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 500, body: "server error"}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil on a 3xx (not in 200..299)" do
- url = "https://example.com/media/redirect"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 302, body: ""}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil on a transport-level error" do
- url = "https://example.com/media/econnrefused"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- {:error, :econnrefused}
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
-
- test "returns nil when the HTTP adapter raises" do
- url = "https://example.com/media/raises"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- raise "boom"
- end)
-
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
- end
- end
-
- describe "sniff_image_type/1 guard clauses" do
- test "returns nil for nil input without making a request" do
- # No Tesla mock is configured; if a request were made, Tesla.Mock would
- # raise and fail the test.
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(nil)
- end
-
- test "returns nil for an empty-string URL without making a request" do
- assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type("")
- end
- end
-end
diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
index 870ad90cc..744ae8704 100644
--- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
+++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs
@@ -215,226 +215,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do
AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert)
end
-
- test "sniffs image/jpeg for octet-stream attachments whose body is an image" do
- url = "https://example.com/media/no-extension/original"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: jpeg}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "application/octet-stream",
- "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
- end
-
- test "sniffs image/jpeg for attachments with a missing mediaType" do
- url = "https://example.com/media/missing-type/original"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: jpeg}
- end)
-
- attachment = %{
- "type" => "Document",
- "url" => [%{"type" => "Link", "href" => url}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
- end
-
- test "leaves non-image octet-stream attachments as octet-stream" do
- url = "https://example.com/media/some-document"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "application/octet-stream",
- "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{mediaType: "application/octet-stream"}] = attachment.url
- end
-
- test "does not sniff when the remote already provided a real mediaType" do
- # No Tesla mock is set up: if the validator tried to fetch, Tesla.Mock
- # would raise and fail the test. A real image type must be kept as-is.
- attachment = %{
- "type" => "Document",
- "mediaType" => "image/png",
- "url" => [
- %{"type" => "Link", "href" => "https://example.com/x", "mediaType" => "image/png"}
- ]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{mediaType: "image/png"}] = attachment.url
- end
- end
-
- describe "fix_media_type fallbacks and sniffing" do
- test "uses mimeType as a fallback when mediaType is absent (real type, not sniffed)" do
- # No Tesla mock: if the validator tried to fetch, the test would fail.
- # A real image type coming from mimeType must be preserved as-is on the
- # outer attachment and on the synthesised url entry (string url form).
- attachment = %{
- "type" => "Document",
- "mimeType" => "image/png",
- "url" => "https://example.com/x.png"
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert attachment.mediaType == "image/png"
- assert [%{mediaType: "image/png"}] = attachment.url
- end
-
- test "prefers mediaType over mimeType when both are present" do
- attachment = %{
- "type" => "Document",
- "mediaType" => "image/gif",
- "mimeType" => "image/png",
- "url" => "https://example.com/x.gif"
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert attachment.mediaType == "image/gif"
- assert [%{mediaType: "image/gif"}] = attachment.url
- end
-
- test "sniffs when the url entry's mediaType is application/octet-stream and the body is an image" do
- # The outer attachment mediaType is not sniffed (no top-level href); only
- # the url entry is. We assert only on the url entry.
- url = "https://example.com/media/mime-type-octet"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: jpeg}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "application/octet-stream",
- "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{mediaType: "image/jpeg"}] = attachment.url
- end
-
- test "sniffs when mediaType is the empty string and the body is an image" do
- url = "https://example.com/media/empty-mediatype"
- png = File.read!("test/fixtures/image.png")
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 200, body: png}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "",
- "url" => [%{"type" => "Link", "href" => url, "mediaType" => ""}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{mediaType: "image/png"}] = attachment.url
- end
-
- test "falls back to the declared type when the sniffer HTTP request fails" do
- url = "https://example.com/media/sniff-404"
-
- Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
- %Tesla.Env{status: 404, body: ""}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "application/octet-stream",
- "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [%{mediaType: "application/octet-stream"}] = attachment.url
- end
-
- test "sniffs each url entry independently" do
- jpeg_url = "https://example.com/media/a"
- octet_url = "https://example.com/media/b"
- jpeg = File.read!("test/fixtures/image.jpg")
-
- Tesla.Mock.mock(fn
- %{method: :get, url: ^jpeg_url} ->
- %Tesla.Env{status: 200, body: jpeg}
-
- %{method: :get, url: ^octet_url} ->
- %Tesla.Env{status: 200, body: "definitely not an image"}
- end)
-
- attachment = %{
- "type" => "Document",
- "mediaType" => "application/octet-stream",
- "url" => [
- %{"type" => "Link", "href" => jpeg_url, "mediaType" => "application/octet-stream"},
- %{"type" => "Link", "href" => octet_url, "mediaType" => "application/octet-stream"}
- ]
- }
-
- {:ok, attachment} =
- attachment
- |> AttachmentValidator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert)
-
- assert [
- %{href: ^jpeg_url, mediaType: "image/jpeg"},
- %{href: ^octet_url, mediaType: "application/octet-stream"}
- ] = attachment.url
- end
end
end
diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs
index 76123cd0f..faeca050e 100644
--- a/test/pleroma/web/mastodon_api/views/status_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs
@@ -668,6 +668,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert expected == StatusView.render("attachment.json", %{attachment: object})
assert_schema(expected, "Attachment", api_spec)
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
test "put the url advertised in the Activity in to the url attribute" do
diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs
index f7e52483c..800159c21 100644
--- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs
+++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs
@@ -71,7 +71,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do
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)
end
end