diff --git a/lib/pleroma/upload/filter/exif/read_description.ex b/lib/pleroma/upload/filter/exif/read_description.ex new file mode 100644 index 000000000..654422b1b --- /dev/null +++ b/lib/pleroma/upload/filter/exif/read_description.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.ReadDescription do + @moduledoc """ + Reads a description from image metadata when the upload has none yet. + + Checks EXIF ImageDescription, then IPTC Caption-Abstract, then XMP. Blank + values and values longer than `[:instance, :description_limit]` are ignored. + """ + + @behaviour Pleroma.Upload.Filter + + @impl true + def filter(%Pleroma.Upload{description: description}) when is_binary(description), + do: {:ok, :noop} + + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) do + max_length = Pleroma.Config.get([:instance, :description_limit]) + + case ExifStripper.Description.read_file(file, max_length: max_length) do + description when is_binary(description) -> + {:ok, :filtered, %{upload | description: description}} + + nil -> + {:ok, :noop} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/exif/strip_location.ex b/lib/pleroma/upload/filter/exif/strip_location.ex new file mode 100644 index 000000000..2e2b79f4c --- /dev/null +++ b/lib/pleroma/upload/filter/exif/strip_location.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.StripLocation do + @moduledoc """ + Strips GPS metadata from uploaded images and overwrites the file in place. + + Supports JPEG, PNG, WebP and ISO-BMFF images (HEIC, AVIF). This is lossless: + the image data is not re-encoded. Other image types are left untouched. + """ + + @behaviour Pleroma.Upload.Filter + + @impl true + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + case ExifStripper.GpsStripper.strip_gps_data(file, file) do + :ok -> {:ok, :filtered} + :noop -> {:ok, :noop} + {:error, _} = error -> error + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/test/fixtures/strip_location_gps.heic b/test/fixtures/strip_location_gps.heic new file mode 100644 index 000000000..f27961d83 Binary files /dev/null and b/test/fixtures/strip_location_gps.heic differ diff --git a/test/fixtures/strip_location_gps.webp b/test/fixtures/strip_location_gps.webp new file mode 100644 index 000000000..9c7724529 Binary files /dev/null and b/test/fixtures/strip_location_gps.webp differ diff --git a/test/pleroma/upload/filter/exif/read_description_test.exs b/test/pleroma/upload/filter/exif/read_description_test.exs new file mode 100644 index 000000000..15d2944cd --- /dev/null +++ b/test/pleroma/upload/filter/exif/read_description_test.exs @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.ReadDescriptionTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Upload.Filter.Exif.ReadDescription + + defp upload(fixture, attrs \\ []) do + path = Path.absname(Path.join("test/fixtures", fixture)) + + struct!( + %Pleroma.Upload{ + name: fixture, + content_type: "image/" <> String.trim_leading(Path.extname(fixture), "."), + path: path, + tempfile: path, + description: nil + }, + attrs + ) + end + + test "keeps description when not empty" do + upload = + upload("image_with_imagedescription_and_caption-abstract.jpg", + description: "Some description" + ) + + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "otherwise returns ImageDescription when present" do + upload = upload("image_with_imagedescription_and_caption-abstract.jpg") + + assert ReadDescription.filter(upload) == + {:ok, :filtered, %{upload | description: "a descriptive white pixel"}} + end + + test "ignores stray data after the image" do + upload = upload("image_with_imagedescription_and_caption-abstract_and_stray_data_after.png") + + assert {:ok, :filtered, %{description: "a descriptive white pixel"}} = + ReadDescription.filter(upload) + + assert ReadDescription.filter(upload("image_with_stray_data_after.png")) == {:ok, :noop} + end + + test "otherwise returns iptc:Caption-Abstract when present" do + upload = upload("image_with_caption-abstract.jpg") + + assert ReadDescription.filter(upload) == + {:ok, :filtered, %{upload | description: "an abstract white pixel"}} + end + + test "reads ImageDescription from WebP and HEIC" do + assert {:ok, :filtered, %{description: "a webp with a location"}} = + ReadDescription.filter(upload("strip_location_gps.webp")) + + assert {:ok, :filtered, %{description: "a heic with a location"}} = + ReadDescription.filter(upload("strip_location_gps.heic")) + end + + test "does nothing when there is no description" do + assert ReadDescription.filter(upload("image_with_no_description.jpg")) == {:ok, :noop} + end + + test "ignores descriptions that exceed the maximum length" do + clear_config([:instance, :description_limit], 5) + + upload = upload("image_with_imagedescription_and_caption-abstract.jpg") + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "ignores content with only whitespace" do + upload = upload("image_with_imagedescription_and_caption-abstract_whitespaces.jpg") + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "does nothing when the file can't be read" do + upload = %{upload("image.jpg") | tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg")} + assert ReadDescription.filter(upload) == {:ok, :noop} + end +end diff --git a/test/pleroma/upload/filter/exif/strip_location_test.exs b/test/pleroma/upload/filter/exif/strip_location_test.exs new file mode 100644 index 000000000..01ca37711 --- /dev/null +++ b/test/pleroma/upload/filter/exif/strip_location_test.exs @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.StripLocationTest do + use Pleroma.DataCase, async: true + + import Pleroma.Tests.ExifHelpers + + alias Pleroma.Upload.Filter.Exif.StripLocation + + for {fixture, content_type} <- [ + {"DSCN0010.jpg", "image/jpeg"}, + {"DSCN0010.png", "image/png"}, + {"strip_location_gps.webp", "image/webp"}, + {"strip_location_gps.heic", "image/heic"} + ] do + test "strips GPS data from #{content_type}" do + upload = upload_copy(unquote(fixture), unquote(content_type)) + assert gps_tags(upload.tempfile) =~ "GPS Latitude" + + assert StripLocation.filter(upload) == {:ok, :filtered} + assert gps_tags(upload.tempfile) == "" + end + end + + test "keeps other EXIF data in HEIC" do + upload = upload_copy("strip_location_gps.heic", "image/heic") + + assert StripLocation.filter(upload) == {:ok, :filtered} + assert tag(upload.tempfile, "ImageDescription") == "a heic with a location" + assert tag(upload.tempfile, "ImageSize") == "64x48" + end + + test "does nothing when there is no GPS data" do + upload = upload_copy("image.jpg", "image/jpeg") + original = File.read!(upload.tempfile) + + assert StripLocation.filter(upload) == {:ok, :noop} + assert File.read!(upload.tempfile) == original + end + + test "ignores non-image uploads" do + upload = upload_copy("DSCN0010.jpg", "application/octet-stream") + + assert StripLocation.filter(upload) == {:ok, :noop} + assert gps_tags(upload.tempfile) =~ "GPS Latitude" + end +end diff --git a/test/pleroma/upload/filter/exiftool/read_description_test.exs b/test/pleroma/upload/filter/exiftool/read_description_test.exs deleted file mode 100644 index 9a1bd61d7..000000000 --- a/test/pleroma/upload/filter/exiftool/read_description_test.exs +++ /dev/null @@ -1,144 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Upload.Filter.Exiftool.ReadDescriptionTest do - use Pleroma.DataCase - alias Pleroma.Upload.Filter - - @uploads %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: nil - } - - test "keeps description when not empty" do - uploads = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: - Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: "Some description" - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :noop} - end - - test "otherwise returns ImageDescription when present" do - uploads_after = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: - Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: "a descriptive white pixel" - } - - assert Filter.Exiftool.ReadDescription.filter(@uploads) == - {:ok, :filtered, uploads_after} - end - - test "Ignores warnings" do - uploads = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract_and_stray_data_after.png", - content_type: "image/png", - path: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png" - ), - tempfile: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png" - ) - } - - assert {:ok, :filtered, %{description: "a descriptive white pixel"}} = - Filter.Exiftool.ReadDescription.filter(uploads) - - uploads = %Pleroma.Upload{ - name: "image_with_stray_data_after.png", - content_type: "image/png", - path: Path.absname("test/fixtures/image_with_stray_data_after.png"), - tempfile: Path.absname("test/fixtures/image_with_stray_data_after.png") - } - - assert {:ok, :filtered, %{description: nil}} = Filter.Exiftool.ReadDescription.filter(uploads) - end - - test "otherwise returns iptc:Caption-Abstract when present" do - upload = %Pleroma.Upload{ - name: "image_with_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - description: nil - } - - upload_after = %Pleroma.Upload{ - name: "image_with_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - description: "an abstract white pixel" - } - - assert Filter.Exiftool.ReadDescription.filter(upload) == - {:ok, :filtered, upload_after} - end - - test "otherwise returns nil" do - uploads = %Pleroma.Upload{ - name: "image_with_no_description.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_no_description.jpg"), - tempfile: Path.absname("test/fixtures/image_with_no_description.jpg"), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end - - test "Return nil when image description from EXIF data exceeds the maximum length" do - clear_config([:instance, :description_limit], 5) - - assert Filter.Exiftool.ReadDescription.filter(@uploads) == - {:ok, :filtered, @uploads} - end - - test "Ignores content with only whitespace" do - uploads = %Pleroma.Upload{ - name: "non-existant.jpg", - content_type: "image/jpeg", - path: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg" - ), - tempfile: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg" - ), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end - - test "Return nil when image description from EXIF data can't be read" do - uploads = %Pleroma.Upload{ - name: "non-existant.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/non-existant.jpg"), - tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg"), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end -end diff --git a/test/support/exif_helpers.ex b/test/support/exif_helpers.ex new file mode 100644 index 000000000..3a8a3b84e --- /dev/null +++ b/test/support/exif_helpers.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tests.ExifHelpers do + @moduledoc """ + Helpers for upload filter tests that use exiftool as an independent oracle. + """ + + @doc "Copies a fixture to a unique temporary file and returns an upload for it." + def upload_copy(fixture, content_type, attrs \\ []) do + source = Path.absname(Path.join("test/fixtures", fixture)) + + tempfile = + Path.join( + System.tmp_dir!(), + "pleroma_exif_#{System.unique_integer([:positive])}#{Path.extname(fixture)}" + ) + + File.cp!(source, tempfile) + ExUnit.Callbacks.on_exit(fn -> File.rm(tempfile) end) + + struct!( + %Pleroma.Upload{ + name: fixture, + content_type: content_type, + path: source, + tempfile: tempfile + }, + attrs + ) + end + + @doc "Returns the GPS tags exiftool finds in a file, as text." + def gps_tags(path), do: exiftool!(["-gps:all", path]) + + @doc "Returns the value of one tag as exiftool reads it." + def tag(path, tag), do: exiftool!(["-s3", "-#{tag}", path]) + + defp exiftool!(args) do + {output, 0} = System.cmd("exiftool", ["-m" | args]) + String.trim(output) + end +end