From addbf235461c4a0edbb4e82a8e68230f5de2559f Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 17:27:56 +0400 Subject: [PATCH] feat(upload): add Exif.StripLocation and Exif.ReadDescription filters Pure-Elixir filters based on exif_stripper. StripLocation also handles WebP and HEIC/AVIF, which the exiftool filter skipped. --- .../upload/filter/exif/read_description.ex | 32 ++++ .../upload/filter/exif/strip_location.ex | 25 +++ test/fixtures/strip_location_gps.heic | Bin 0 -> 2835 bytes test/fixtures/strip_location_gps.webp | Bin 0 -> 1382 bytes .../filter/exif/read_description_test.exs | 85 +++++++++++ .../filter/exif/strip_location_test.exs | 49 ++++++ .../filter/exiftool/read_description_test.exs | 144 ------------------ test/support/exif_helpers.ex | 44 ++++++ 8 files changed, 235 insertions(+), 144 deletions(-) create mode 100644 lib/pleroma/upload/filter/exif/read_description.ex create mode 100644 lib/pleroma/upload/filter/exif/strip_location.ex create mode 100644 test/fixtures/strip_location_gps.heic create mode 100644 test/fixtures/strip_location_gps.webp create mode 100644 test/pleroma/upload/filter/exif/read_description_test.exs create mode 100644 test/pleroma/upload/filter/exif/strip_location_test.exs delete mode 100644 test/pleroma/upload/filter/exiftool/read_description_test.exs create mode 100644 test/support/exif_helpers.ex 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 0000000000000000000000000000000000000000..f27961d831a13737c89ef57c4f1394c0b2cac7a8 GIT binary patch literal 2835 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 +# 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