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.
This commit is contained in:
parent
51800008eb
commit
addbf23546
8 changed files with 235 additions and 144 deletions
32
lib/pleroma/upload/filter/exif/read_description.ex
Normal file
32
lib/pleroma/upload/filter/exif/read_description.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
25
lib/pleroma/upload/filter/exif/strip_location.ex
Normal file
25
lib/pleroma/upload/filter/exif/strip_location.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
BIN
test/fixtures/strip_location_gps.heic
vendored
Normal file
BIN
test/fixtures/strip_location_gps.heic
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/strip_location_gps.webp
vendored
Normal file
BIN
test/fixtures/strip_location_gps.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
85
test/pleroma/upload/filter/exif/read_description_test.exs
Normal file
85
test/pleroma/upload/filter/exif/read_description_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
49
test/pleroma/upload/filter/exif/strip_location_test.exs
Normal file
49
test/pleroma/upload/filter/exif/strip_location_test.exs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
44
test/support/exif_helpers.ex
Normal file
44
test/support/exif_helpers.ex
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
|
||||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue