diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex
index c7ee47c6e..11f041c57 100644
--- a/lib/pleroma/reverse_proxy.ex
+++ b/lib/pleroma/reverse_proxy.ex
@@ -59,7 +59,9 @@ defmodule Pleroma.ReverseProxy do
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `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
* `req_headers`, `resp_headers` additional headers.
@@ -386,7 +388,27 @@ defmodule Pleroma.ReverseProxy do
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
else
- headers
+ if opt == true do
+ headers
+ else
+ name = inline_filename(content_type)
+
+ disposition =
+ if name do
+ "inline; filename=\"#{name}\""
+ else
+ "inline"
+ end
+
+ List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
+ end
+ end
+ end
+
+ defp inline_filename(content_type) do
+ case MIME.extensions(content_type) do
+ [ext | _] when ext != "" -> "inline.#{ext}"
+ _ -> nil
end
end
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
new file mode 100644
index 000000000..19215b627
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex
@@ -0,0 +1,59 @@
+# 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 5ee9e7549..b6e30a438 100644
--- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators
+ alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
import Ecto.Changeset
@@ -61,9 +62,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
end
def fix_media_type(data) do
- Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream")
+ 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)
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/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs
index ec4470379..5f75f2a07 100644
--- a/test/pleroma/reverse_proxy_test.exs
+++ b/test/pleroma/reverse_proxy_test.exs
@@ -298,21 +298,21 @@ defmodule Pleroma.ReverseProxyTest do
end
end
- defp disposition_headers_mock(headers) do
+ defp disposition_headers_mock(headers, url \\ "/disposition") do
ClientMock
- |> expect(:request, fn :get, "/disposition", _, _, _ ->
- Registry.register(ClientMock, "/disposition", 0)
+ |> expect(:request, fn :get, ^url, _, _, _ ->
+ Registry.register(ClientMock, url, 0)
- {:ok, 200, headers, %{url: "/disposition"}}
+ {:ok, 200, headers, %{url: url}}
end)
- |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
- case Registry.lookup(ClientMock, "/disposition") do
+ |> expect(:stream_body, 2, fn %{url: ^url} = client ->
+ case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
- Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
+ Registry.update_value(ClientMock, url, &(&1 + 1))
{:ok, "", client}
[{_, 1}] ->
- Registry.unregister(ClientMock, "/disposition")
+ Registry.unregister(ClientMock, url)
:done
end
end)
@@ -328,6 +328,40 @@ defmodule Pleroma.ReverseProxyTest do
conn = ReverseProxy.call(conn, "/disposition")
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-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
+
+ 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
@@ -340,6 +374,132 @@ defmodule Pleroma.ReverseProxyTest do
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
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 "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
+ # 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 "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.
+ disposition_headers_mock([
+ {"content-type", "image/bmp"},
+ {"content-disposition", "attachment; filename=\"upstream.bmp\""},
+ {"content-length", "0"}
+ ])
+
+ conn =
+ ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
+
+ [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
+ end
end
describe "content-type sanitisation" do
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
new file mode 100644
index 000000000..52b1b67a1
--- /dev/null
+++ b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs
@@ -0,0 +1,178 @@
+# 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 744ae8704..870ad90cc 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,5 +215,226 @@ 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