Split out media processing tools into PhilomenaMedia namespace

This commit is contained in:
Liam 2024-06-02 00:15:09 -04:00
parent 0d6acafc96
commit c8d696540f
37 changed files with 1266 additions and 541 deletions

View file

@ -10,7 +10,7 @@ defmodule Mix.Tasks.UploadToS3 do
}
alias Philomena.Images.Thumbnailer
alias Philomena.Objects
alias PhilomenaMedia.Objects
alias PhilomenaQuery.Batch
import Ecto.Query

View file

@ -4,7 +4,7 @@ defmodule Philomena.Adverts.Uploader do
"""
alias Philomena.Adverts.Advert
alias Philomena.Uploader
alias PhilomenaMedia.Uploader
def analyze_upload(advert, params) do
Uploader.analyze_upload(advert, "image", params["image"], &Advert.image_changeset/2)

View file

@ -1,56 +0,0 @@
defmodule Philomena.Analyzers do
@moduledoc """
Utilities for analyzing the format and various attributes of uploaded files.
"""
alias Philomena.Mime
alias Philomena.Analyzers.Gif
alias Philomena.Analyzers.Jpeg
alias Philomena.Analyzers.Png
alias Philomena.Analyzers.Svg
alias Philomena.Analyzers.Webm
@doc """
Returns an {:ok, analyzer} tuple, with the analyzer being a module capable
of analyzing this content type, or :error.
To use an analyzer, call the analyze/1 method on it with the path to the
file. It will return a map such as the following:
%{
animated?: false,
dimensions: {800, 600},
duration: 0.0,
extension: "png",
mime_type: "image/png"
}
"""
@spec analyzer(binary()) :: {:ok, module()} | :error
def analyzer(content_type)
def analyzer("image/gif"), do: {:ok, Gif}
def analyzer("image/jpeg"), do: {:ok, Jpeg}
def analyzer("image/png"), do: {:ok, Png}
def analyzer("image/svg+xml"), do: {:ok, Svg}
def analyzer("video/webm"), do: {:ok, Webm}
def analyzer(_content_type), do: :error
@doc """
Attempts a mime check and analysis on the given pathname or Plug.Upload.
"""
@spec analyze(Plug.Upload.t() | String.t()) :: {:ok, map()} | :error
def analyze(%Plug.Upload{path: path}), do: analyze(path)
def analyze(path) when is_binary(path) do
with {:ok, mime} <- Mime.file(path),
{:ok, analyzer} <- analyzer(mime) do
{:ok, analyzer.analyze(path)}
else
error ->
error
end
end
def analyze(_path), do: :error
end

View file

@ -4,7 +4,7 @@ defmodule Philomena.Badges.Uploader do
"""
alias Philomena.Badges.Badge
alias Philomena.Uploader
alias PhilomenaMedia.Uploader
def analyze_upload(badge, params) do
Uploader.analyze_upload(badge, "image", params["image"], &Badge.image_changeset/2)

View file

@ -1,21 +0,0 @@
defmodule Philomena.Filename do
@moduledoc """
Utilities for building arbitrary filenames for uploaded files.
"""
@spec build(String.t()) :: String.t()
def build(extension) do
[
time_identifier(DateTime.utc_now()),
"/",
UUID.uuid1(),
".",
extension
]
|> Enum.join()
end
defp time_identifier(time) do
Enum.join([time.year, time.month, time.day], "/")
end
end

View file

@ -36,9 +36,9 @@ defmodule Philomena.ImageIntensities do
{:error, %Ecto.Changeset{}}
"""
def create_image_intensity(image, attrs \\ %{}) do
def create_image_intensity(image, attrs \\ %PhilomenaMedia.Intensities{}) do
%ImageIntensity{image_id: image.id}
|> ImageIntensity.changeset(attrs)
|> ImageIntensity.changeset(Map.from_struct(attrs))
|> Repo.insert()
end
@ -56,7 +56,7 @@ defmodule Philomena.ImageIntensities do
"""
def update_image_intensity(%ImageIntensity{} = image_intensity, attrs) do
image_intensity
|> ImageIntensity.changeset(attrs)
|> ImageIntensity.changeset(Map.from_struct(attrs))
|> Repo.update()
end

View file

@ -3,15 +3,16 @@ defmodule Philomena.Images.Thumbnailer do
Prevewing and thumbnailing logic for Images.
"""
alias PhilomenaMedia.Processors
alias PhilomenaMedia.Analyzers
alias PhilomenaMedia.Uploader
alias PhilomenaMedia.Objects
alias PhilomenaMedia.Sha512
alias Philomena.DuplicateReports
alias Philomena.ImageIntensities
alias Philomena.ImagePurgeWorker
alias Philomena.Images.Image
alias Philomena.Processors
alias Philomena.Analyzers
alias Philomena.Uploader
alias Philomena.Objects
alias Philomena.Sha512
alias Philomena.Repo
@versions [

View file

@ -5,7 +5,7 @@ defmodule Philomena.Images.Uploader do
alias Philomena.Images.Thumbnailer
alias Philomena.Images.Image
alias Philomena.Uploader
alias PhilomenaMedia.Uploader
def analyze_upload(image, params) do
Uploader.analyze_upload(image, "image", params["image"], &Image.image_changeset/2)

View file

@ -1,23 +0,0 @@
defmodule Philomena.Intensities do
@doc """
Gets the corner intensities of the given image file.
The image file must be in the PNG or JPEG format.
"""
@spec file(String.t()) :: {:ok, map()} | :error
def file(input) do
System.cmd("image-intensities", [input])
|> case do
{output, 0} ->
[nw, ne, sw, se] =
output
|> String.trim()
|> String.split("\t")
|> Enum.map(&String.to_float/1)
{:ok, %{nw: nw, ne: ne, sw: sw, se: se}}
_error ->
:error
end
end
end

View file

@ -1,37 +0,0 @@
defmodule Philomena.Mime do
@type mime :: String.t()
@doc """
Gets the mime type of the given pathname.
"""
@spec file(String.t()) :: {:ok, mime()} | :error
def file(path) do
System.cmd("file", ["-b", "--mime-type", path])
|> case do
{output, 0} ->
true_mime(String.trim(output))
_error ->
:error
end
end
@doc """
Provides the "true" content type of this file.
Some files are identified incorrectly as a mime type they should not be.
These incorrect mime types (and their "corrected") versions are:
- image/svg -> image/svg+xml
- audio/webm -> video/webm
"""
@spec true_mime(String.t()) :: {:ok, mime()}
def true_mime("image/svg"), do: {:ok, "image/svg+xml"}
def true_mime("audio/webm"), do: {:ok, "video/webm"}
def true_mime(mime)
when mime in ~W(image/gif image/jpeg image/png image/svg+xml video/webm),
do: {:ok, mime}
def true_mime(mime), do: {:unsupported_mime, mime}
end

View file

@ -1,148 +0,0 @@
defmodule Philomena.Objects do
@moduledoc """
Replication wrapper for object storage backends.
"""
alias Philomena.Mime
require Logger
#
# Fetch a key from the storage backend and
# write it into the destination file.
#
# sobelow_skip ["Traversal.FileModule"]
@spec download_file(String.t(), String.t()) :: any()
def download_file(key, file_path) do
contents =
backends()
|> Enum.find_value(fn opts ->
ExAws.S3.get_object(opts[:bucket], key)
|> ExAws.request(opts[:config_overrides])
|> case do
{:ok, result} -> result
_ -> nil
end
end)
File.write!(file_path, contents.body)
end
#
# Upload a file using a single API call, writing the
# contents from the given path to storage.
#
# sobelow_skip ["Traversal.FileModule"]
@spec put(String.t(), String.t()) :: any()
def put(key, file_path) do
{_, mime} = Mime.file(file_path)
contents = File.read!(file_path)
run_all(fn opts ->
ExAws.S3.put_object(opts[:bucket], key, contents, content_type: mime)
|> ExAws.request!(opts[:config_overrides])
end)
end
#
# Upload a file using multiple API calls, writing the
# contents from the given path to storage.
#
@spec upload(String.t(), String.t()) :: any()
def upload(key, file_path) do
# Workaround for API rate limit issues on R2
put(key, file_path)
end
#
# Copies a key from the source to the destination,
# overwriting the destination object if its exists.
#
@spec copy(String.t(), String.t()) :: any()
def copy(source_key, dest_key) do
# Potential workaround for inconsistent PutObjectCopy on R2
#
# run_all(fn opts->
# ExAws.S3.put_object_copy(opts[:bucket], dest_key, opts[:bucket], source_key)
# |> ExAws.request!(opts[:config_overrides])
# end)
try do
file_path = Briefly.create!()
download_file(source_key, file_path)
upload(dest_key, file_path)
catch
_kind, _value -> Logger.warning("Failed to copy #{source_key} -> #{dest_key}")
end
end
#
# Removes the key from storage.
#
@spec delete(String.t()) :: any()
def delete(key) do
run_all(fn opts ->
ExAws.S3.delete_object(opts[:bucket], key)
|> ExAws.request!(opts[:config_overrides])
end)
end
#
# Removes all given keys from storage.
#
@spec delete_multiple([String.t()]) :: any()
def delete_multiple(keys) do
run_all(fn opts ->
ExAws.S3.delete_multiple_objects(opts[:bucket], keys)
|> ExAws.request!(opts[:config_overrides])
end)
end
defp run_all(wrapped) do
fun = fn opts ->
try do
wrapped.(opts)
:ok
catch
_kind, _value -> :error
end
end
backends()
|> Task.async_stream(fun, timeout: :infinity)
|> Enum.any?(fn {_, v} -> v == :error end)
|> case do
true ->
Logger.warning("Failed to operate on all backends")
_ ->
:ok
end
end
defp backends do
primary_opts() ++ replica_opts()
end
defp primary_opts do
[
%{
config_overrides: Application.fetch_env!(:philomena, :s3_primary_options),
bucket: Application.fetch_env!(:philomena, :s3_primary_bucket)
}
]
end
defp replica_opts do
replica_bucket = Application.get_env(:philomena, :s3_secondary_bucket)
if not is_nil(replica_bucket) do
[
%{
config_overrides: Application.fetch_env!(:philomena, :s3_secondary_options),
bucket: replica_bucket
}
]
else
[]
end
end
end

View file

@ -1,78 +0,0 @@
defmodule Philomena.Processors do
@moduledoc """
Utilities for processing uploads.
Processors have 3 methods available:
- process/3:
Takes an analysis, file path, and version list and generates an
"edit script" that represents how to store this file according to the
given version list. See Philomena.Images.Thumbnailer for more
information on how this works.
- post_process/2:
Takes an analysis and file path and performs optimizations on the
upload. See Philomena.Images.Thumbnailer for more information on how this
works.
- intensities/2:
Takes an analysis and file path and generates an intensities map
appropriate for use by Philomena.DuplicateReports.
"""
alias Philomena.Processors.Gif
alias Philomena.Processors.Jpeg
alias Philomena.Processors.Png
alias Philomena.Processors.Svg
alias Philomena.Processors.Webm
@doc """
Returns a processor, with the processor being a module capable
of processing this content type, or nil.
"""
@spec processor(String.t()) :: module() | nil
def processor(content_type)
def processor("image/gif"), do: Gif
def processor("image/jpeg"), do: Jpeg
def processor("image/png"), do: Png
def processor("image/svg+xml"), do: Svg
def processor("video/webm"), do: Webm
def processor(_content_type), do: nil
@doc """
Takes a MIME type and version list and generates a list of versions to be
generated (e.g., ["thumb.png"]). List contents differ based on file type.
"""
@spec versions(String.t(), keyword) :: [String.t()]
def versions(mime_type, valid_sizes) do
processor(mime_type).versions(valid_sizes)
end
@doc """
Takes an analyzer, file path, and version list and runs the appropriate
processor's process/3.
"""
@spec process(map(), String.t(), keyword) :: map()
def process(analysis, file, versions) do
processor(analysis.mime_type).process(analysis, file, versions)
end
@doc """
Takes an analyzer and file path and runs the appropriate processor's
post_process/2.
"""
@spec post_process(map(), String.t()) :: map()
def post_process(analysis, file) do
processor(analysis.mime_type).post_process(analysis, file)
end
@doc """
Takes an analyzer and file path and runs the appropriate processor's
intensities/2.
"""
@spec intensities(map(), String.t()) :: map()
def intensities(analysis, file) do
processor(analysis.mime_type).intensities(analysis, file)
end
end

View file

@ -4,7 +4,7 @@ defmodule Philomena.Tags.Uploader do
"""
alias Philomena.Tags.Tag
alias Philomena.Uploader
alias PhilomenaMedia.Uploader
def analyze_upload(tag, params) do
Uploader.analyze_upload(tag, "image", params["image"], &Tag.image_changeset/2)

View file

@ -1,125 +0,0 @@
defmodule Philomena.Uploader do
@moduledoc """
Upload and processing callback logic for image files.
"""
alias Philomena.Filename
alias Philomena.Analyzers
alias Philomena.Objects
alias Philomena.Sha512
import Ecto.Changeset
@doc """
Performs analysis of the passed Plug.Upload, and invokes a changeset
callback on the model or changeset passed in with attributes set on
the field_name.
"""
@spec analyze_upload(any(), String.t(), Plug.Upload.t(), (any(), map() -> Ecto.Changeset.t())) ::
Ecto.Changeset.t()
def analyze_upload(model_or_changeset, field_name, upload_parameter, changeset_fn) do
with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
analysis <- extra_attributes(analysis, upload_parameter) do
removed =
model_or_changeset
|> change()
|> get_field(field(field_name))
attributes =
%{
"name" => analysis.name,
"width" => analysis.width,
"height" => analysis.height,
"size" => analysis.size,
"format" => analysis.extension,
"mime_type" => analysis.mime_type,
"duration" => analysis.duration,
"aspect_ratio" => analysis.aspect_ratio,
"orig_sha512_hash" => analysis.sha512,
"sha512_hash" => analysis.sha512,
"is_animated" => analysis.animated?
}
|> prefix_attributes(field_name)
|> Map.put(field_name, analysis.new_name)
|> Map.put(upload_key(field_name), upload_parameter.path)
|> Map.put(remove_key(field_name), removed)
changeset_fn.(model_or_changeset, attributes)
else
{:unsupported_mime, mime} ->
attributes = prefix_attributes(%{"mime_type" => mime}, field_name)
changeset_fn.(model_or_changeset, attributes)
_error ->
changeset_fn.(model_or_changeset, %{})
end
end
@doc """
Writes the file to permanent storage. This should be the second-to-last step
in the transaction.
"""
@spec persist_upload(any(), String.t(), String.t()) :: any()
def persist_upload(model, file_root, field_name) do
source = Map.get(model, field(upload_key(field_name)))
dest = Map.get(model, field(field_name))
target = Path.join(file_root, dest)
persist_file(target, source)
end
@doc """
Persist an arbitrary file to storage at the given path with the correct
content type and permissions.
"""
def persist_file(path, file) do
Objects.upload(path, file)
end
@doc """
Removes the old file from permanent storage. This should be the last step in
the transaction.
"""
@spec unpersist_old_upload(any(), String.t(), String.t()) :: any()
def unpersist_old_upload(model, file_root, field_name) do
model
|> Map.get(field(remove_key(field_name)))
|> try_remove(file_root)
end
defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do
{width, height} = analysis.dimensions
aspect_ratio = aspect_ratio(width, height)
stat = File.stat!(path)
sha512 = Sha512.file(path)
new_name = Filename.build(analysis.extension)
analysis
|> Map.put(:size, stat.size)
|> Map.put(:name, filename)
|> Map.put(:width, width)
|> Map.put(:height, height)
|> Map.put(:sha512, sha512)
|> Map.put(:new_name, new_name)
|> Map.put(:aspect_ratio, aspect_ratio)
end
defp aspect_ratio(_, 0), do: 0.0
defp aspect_ratio(w, h), do: w / h
defp try_remove("", _file_root), do: nil
defp try_remove(nil, _file_root), do: nil
defp try_remove(file, file_root) do
Objects.delete(Path.join(file_root, file))
end
defp prefix_attributes(map, prefix),
do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end)
defp upload_key(field_name), do: "uploaded_#{field_name}"
defp remove_key(field_name), do: "removed_#{field_name}"
defp field(field_name), do: String.to_existing_atom(field_name)
end

View file

@ -4,7 +4,7 @@ defmodule Philomena.Users.Uploader do
"""
alias Philomena.Users.User
alias Philomena.Uploader
alias PhilomenaMedia.Uploader
def analyze_upload(user, params) do
Uploader.analyze_upload(user, "avatar", params["avatar"], &User.avatar_changeset/2)

View file

@ -0,0 +1,71 @@
defmodule PhilomenaMedia.Analyzers do
@moduledoc """
Utilities for analyzing the format and various attributes of uploaded files.
"""
alias PhilomenaMedia.Analyzers.{Gif, Jpeg, Png, Svg, Webm}
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Mime
@doc """
Returns an `{:ok, analyzer}` tuple, with the analyzer being a module capable
of analyzing this media type, or `:error`.
The allowed MIME types are:
- `image/gif`
- `image/jpeg`
- `image/png`
- `image/svg+xml`
- `video/webm`
> #### Info {: .info}
>
> This is an interface intended for use when the MIME type is already known.
> Using an analyzer not matched to the file may cause unexpected results.
## Examples
{:ok, analyzer} = PhilomenaMedia.Analyzers.analyzer("image/png")
:error = PhilomenaMedia.Analyzers.analyzer("application/octet-stream")
"""
@spec analyzer(Mime.t()) :: {:ok, module()} | :error
def analyzer(content_type)
def analyzer("image/gif"), do: {:ok, Gif}
def analyzer("image/jpeg"), do: {:ok, Jpeg}
def analyzer("image/png"), do: {:ok, Png}
def analyzer("image/svg+xml"), do: {:ok, Svg}
def analyzer("video/webm"), do: {:ok, Webm}
def analyzer(_content_type), do: :error
@doc """
Attempts a MIME type check and analysis on the given path or `m:Plug.Upload`.
## Examples
file = "image_file.png"
{:ok, %Result{...}} = Analyzers.analyze(file)
file = %Plug.Upload{...}
{:ok, %Result{...}} = Analyzers.analyze(file)
file = "text_file.txt"
:error = Analyzers.analyze(file)
"""
@spec analyze(Plug.Upload.t() | Path.t()) :: {:ok, Result.t()} | :error
def analyze(%Plug.Upload{path: path}), do: analyze(path)
def analyze(path) when is_binary(path) do
with {:ok, mime} <- Mime.file(path),
{:ok, analyzer} <- analyzer(mime) do
{:ok, analyzer.analyze(path)}
else
error ->
error
end
end
def analyze(_path), do: :error
end

View file

@ -0,0 +1,5 @@
defmodule PhilomenaMedia.Analyzers.Analyzer do
@moduledoc false
@callback analyze(Path.t()) :: PhilomenaMedia.Analyzers.Result.t()
end

View file

@ -1,8 +1,16 @@
defmodule Philomena.Analyzers.Gif do
defmodule PhilomenaMedia.Analyzers.Gif do
@moduledoc false
alias PhilomenaMedia.Analyzers.Analyzer
alias PhilomenaMedia.Analyzers.Result
@behaviour Analyzer
@spec analyze(Path.t()) :: Result.t()
def analyze(file) do
stats = stats(file)
%{
%Result{
extension: "gif",
mime_type: "image/gif",
animated?: stats.animated?,

View file

@ -1,8 +1,16 @@
defmodule Philomena.Analyzers.Jpeg do
defmodule PhilomenaMedia.Analyzers.Jpeg do
@moduledoc false
alias PhilomenaMedia.Analyzers.Analyzer
alias PhilomenaMedia.Analyzers.Result
@behaviour Analyzer
@spec analyze(Path.t()) :: Result.t()
def analyze(file) do
stats = stats(file)
%{
%Result{
extension: "jpg",
mime_type: "image/jpeg",
animated?: false,

View file

@ -1,8 +1,16 @@
defmodule Philomena.Analyzers.Png do
defmodule PhilomenaMedia.Analyzers.Png do
@moduledoc false
alias PhilomenaMedia.Analyzers.Analyzer
alias PhilomenaMedia.Analyzers.Result
@behaviour Analyzer
@spec analyze(Path.t()) :: Result.t()
def analyze(file) do
stats = stats(file)
%{
%Result{
extension: "png",
mime_type: "image/png",
animated?: stats.animated?,

View file

@ -0,0 +1,36 @@
defmodule PhilomenaMedia.Analyzers.Result do
@moduledoc """
The analysis result.
- `:animated?` - whether the media file is animated
- `:dimensions` - the maximum dimensions of the media file, as `{width, height}`
- `:duration` - the maximum duration of the media file, or 0 if not applicable
- `:extension` - the file extension the media file should take, based on its contents
- `:mime_type` - the MIME type the media file should take, based on its contents
## Example
%Result{
animated?: false,
dimensions: {800, 600},
duration: 0.0,
extension: "png",
mime_type: "image/png"
}
"""
@type t :: %__MODULE__{
animated?: boolean(),
dimensions: {integer(), integer()},
duration: float(),
extension: String.t(),
mime_type: String.t()
}
defstruct animated?: false,
dimensions: {0, 0},
duration: 0.0,
extension: "",
mime_type: "application/octet-stream"
end

View file

@ -1,8 +1,16 @@
defmodule Philomena.Analyzers.Svg do
defmodule PhilomenaMedia.Analyzers.Svg do
@moduledoc false
alias PhilomenaMedia.Analyzers.Analyzer
alias PhilomenaMedia.Analyzers.Result
@behaviour Analyzer
@spec analyze(Path.t()) :: Result.t()
def analyze(file) do
stats = stats(file)
%{
%Result{
extension: "svg",
mime_type: "image/svg+xml",
animated?: false,

View file

@ -1,8 +1,16 @@
defmodule Philomena.Analyzers.Webm do
defmodule PhilomenaMedia.Analyzers.Webm do
@moduledoc false
alias PhilomenaMedia.Analyzers.Analyzer
alias PhilomenaMedia.Analyzers.Result
@behaviour Analyzer
@spec analyze(Path.t()) :: Result.t()
def analyze(file) do
stats = stats(file)
%{
%Result{
extension: "webm",
mime_type: "video/webm",
animated?: stats.animated?,

View file

@ -0,0 +1,36 @@
defmodule PhilomenaMedia.Filename do
@moduledoc """
Utilities for building arbitrary filenames for uploaded files.
"""
@type extension :: String.t()
@doc """
This function builds a replacement "filename key" based on the supplied file extension.
Names are generated in the form `year/month/day/uuid.ext`. It is recommended to avoid
providing user-controlled file-extensions to this function; select them from a list of
known extensions instead.
## Example
iex> PhilomenaMedia.Filename.build("png")
"2024/1/1/0bce8eea-17e0-11ef-b7d4-0242ac120006.png"
"""
@spec build(extension()) :: String.t()
def build(extension) do
[
time_identifier(DateTime.utc_now()),
"/",
UUID.uuid1(),
".",
extension
]
|> Enum.join()
end
defp time_identifier(time) do
Enum.join([time.year, time.month, time.day], "/")
end
end

View file

@ -0,0 +1,68 @@
defmodule PhilomenaMedia.Intensities do
@moduledoc """
Corner intensities are a simple mechanism for automatic image deduplication,
designed for a time when computer vision was an expensive technology and
resources were scarce.
Each image is divided into quadrants; image with odd numbers of pixels
on either dimension overlap quadrants by one pixel. The luma (brightness)
value corresponding each the pixel is computed according to BTU.709 primaries,
and its value is added to a sum for each quadrant. Finally, the value is divided
by the number of pixels in the quadrant to produce an average. The minimum luma
value of any pixel is 0, and the maximum is 255, so an average will be between
these values. Transparent pixels are composited on black before processing.
By using a range search in the database, this produces a reverse image search which
suffers no dimensionality issues, is exceptionally fast to evaluate, and is independent
of image dimensions, with poor precision and a poor-to-fair accuracy.
"""
@type t :: %__MODULE__{
nw: float(),
ne: float(),
sw: float(),
se: float()
}
defstruct nw: 0.0,
ne: 0.0,
sw: 0.0,
se: 0.0
@doc """
Gets the corner intensities of the given image file.
The image file must be in the PNG or JPEG format.
> #### Info {: .info}
>
> Clients should prefer to use `m:PhilomenaMedia.Processors.intensities/2`, as it handles
> media files of any type supported by this library, not just PNG or JPEG.
## Examples
iex> Intensities.file("image.png")
{:ok, %Intensities{nw: 111.689148, ne: 116.228048, sw: 93.268433, se: 104.630064}}
iex> Intensities.file("nonexistent.jpg")
:error
"""
@spec file(Path.t()) :: {:ok, t()} | :error
def file(input) do
System.cmd("image-intensities", [input])
|> case do
{output, 0} ->
[nw, ne, sw, se] =
output
|> String.trim()
|> String.split("\t")
|> Enum.map(&String.to_float/1)
{:ok, %__MODULE__{nw: nw, ne: ne, sw: sw, se: se}}
_error ->
:error
end
end
end

View file

@ -0,0 +1,67 @@
defmodule PhilomenaMedia.Mime do
@moduledoc """
Utilities for determining the MIME type of a file via parsing.
Many MIME type libraries assume the MIME type of the file by reading file extensions.
This is inherently unreliable, as many websites disguise the content types of files with
specific names for cost or bandwidth saving reasons. As processing depends on correctly
identifying the type of a file, parsing the file contents is necessary.
"""
@type t :: String.t()
@doc """
Gets the MIME type of the given pathname.
## Examples
iex> PhilomenaMedia.Mime.file("image.png")
{:ok, "image/png"}
iex> PhilomenaMedia.Mime.file("file.txt")
{:unsupported_mime, "text/plain"}
iex> PhilomenaMedia.Mime.file("nonexistent.file")
:error
"""
@spec file(Path.t()) :: {:ok, t()} | {:unsupported_mime, t()} | :error
def file(path) do
System.cmd("file", ["-b", "--mime-type", path])
|> case do
{output, 0} ->
true_mime(String.trim(output))
_error ->
:error
end
end
@doc """
Provides the "true" MIME type of this file.
Some files are identified as a type they should not be based on how they are used by
this library. These MIME types (and their "corrected") versions are:
- `image/svg` -> `image/svg+xml`
- `audio/webm` -> `video/webm`
## Examples
iex> PhilomenaMedia.Mime.file("image.svg")
"image/svg+xml"
iex> PhilomenaMedia.Mime.file("audio.webm")
"video/webm"
"""
@spec true_mime(String.t()) :: {:ok, t()} | {:unsupported_mime, t()}
def true_mime("image/svg"), do: {:ok, "image/svg+xml"}
def true_mime("audio/webm"), do: {:ok, "video/webm"}
def true_mime(mime)
when mime in ~W(image/gif image/jpeg image/png image/svg+xml video/webm),
do: {:ok, mime}
def true_mime(mime), do: {:unsupported_mime, mime}
end

View file

@ -0,0 +1,236 @@
defmodule PhilomenaMedia.Objects do
@moduledoc """
Replication wrapper for object storage backends.
While cloud services can be an inexpensive way to access large amounts of storage, they
are inherently less available than local file-based storage. For this reason, it is generally
recommended to maintain a secondary storage provider, such as in the
[3-2-1 backup strategy](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/).
Functions in this module replicate operations on both the primary and secondary storage
providers. Alternatively, a mode with only a primary storage provider is supported.
This module assumes storage endpoints are S3-compatible and can be communicated with via the
`m:ExAws` module. This does not preclude the usage of local file-based storage, which can be
accomplished with the [`s3proxy` project](https://github.com/gaul/s3proxy). The development
repository provides an example of `s3proxy` in use.
Bucket names should be set with configuration on `s3_primary_bucket` and `s3_secondary_bucket`.
If `s3_secondary_bucket` is not set, then only the primary will be used. However, the primary
bucket name must always be set.
These are read from environment variables at runtime by Philomena.
# S3/Object store config
config :philomena, :s3_primary_bucket, System.fetch_env!("S3_BUCKET")
config :philomena, :s3_secondary_bucket, System.get_env("ALT_S3_BUCKET")
Additional options (e.g. controlling the remote endpoint used) may be set with
`s3_primary_options` and `s3_secondary_options` keys. This allows you to use a provider other
than AWS, like [Cloudflare R2](https://developers.cloudflare.com/r2/).
These are read from environment variables at runtime by Philomena.
config :philomena, :s3_primary_options,
region: System.get_env("S3_REGION", "us-east-1"),
scheme: System.fetch_env!("S3_SCHEME"),
host: System.fetch_env!("S3_HOST"),
port: System.fetch_env!("S3_PORT"),
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"),
http_opts: [timeout: 180_000, recv_timeout: 180_000]
"""
alias PhilomenaMedia.Mime
require Logger
@type key :: String.t()
@doc """
Fetch a key from the storage backend and write it into the destination path.
## Example
key = "2024/1/1/5/full.png"
Objects.download_file(key, file_path)
"""
# sobelow_skip ["Traversal.FileModule"]
@spec download_file(key(), Path.t()) :: :ok
def download_file(key, file_path) do
contents =
backends()
|> Enum.find_value(fn opts ->
ExAws.S3.get_object(opts[:bucket], key)
|> ExAws.request(opts[:config_overrides])
|> case do
{:ok, result} -> result
_ -> nil
end
end)
File.write!(file_path, contents.body)
end
@doc """
Upload a file using a single API call, writing the contents from the given path to storage.
## Example
key = "2024/1/1/5/full.png"
Objects.put(key, file_path)
"""
# sobelow_skip ["Traversal.FileModule"]
@spec put(key(), Path.t()) :: :ok
def put(key, file_path) do
{_, mime} = Mime.file(file_path)
contents = File.read!(file_path)
run_all(fn opts ->
ExAws.S3.put_object(opts[:bucket], key, contents, content_type: mime)
|> ExAws.request!(opts[:config_overrides])
end)
end
@doc """
Upload a file using multiple API calls, writing the contents from the given path to storage.
## Example
key = "2024/1/1/5/full.png"
Objects.upload(key, file_path)
"""
@spec upload(key(), Path.t()) :: :ok
def upload(key, file_path) do
# Workaround for API rate limit issues on R2
put(key, file_path)
end
@doc """
Copies a key from the source to the destination, overwriting the destination object if its exists.
> #### Warning {: .warning}
>
> `copy/2` does not use the `PutObjectCopy` S3 request. It downloads the file and uploads it again.
> This may use more disk space than expected if the file is large.
## Example
source_key = "2024/1/1/5/full.png"
dest_key = "2024/1/1/5-a5323e542e0f/full.png"
Objects.copy(source_key, dest_key)
"""
@spec copy(key(), key()) :: :ok
def copy(source_key, dest_key) do
# Potential workaround for inconsistent PutObjectCopy on R2
#
# run_all(fn opts->
# ExAws.S3.put_object_copy(opts[:bucket], dest_key, opts[:bucket], source_key)
# |> ExAws.request!(opts[:config_overrides])
# end)
try do
file_path = Briefly.create!()
download_file(source_key, file_path)
upload(dest_key, file_path)
catch
_kind, _value -> Logger.warning("Failed to copy #{source_key} -> #{dest_key}")
end
:ok
end
@doc """
Removes the key from storage.
## Example
key = "2024/1/1/5/full.png"
Objects.delete(key)
"""
@spec delete(key()) :: :ok
def delete(key) do
run_all(fn opts ->
ExAws.S3.delete_object(opts[:bucket], key)
|> ExAws.request!(opts[:config_overrides])
end)
end
@doc """
Removes all given keys from storage.
## Example
keys = [
"2024/1/1/5/full.png",
"2024/1/1/5/small.png",
"2024/1/1/5/thumb.png",
"2024/1/1/5/thumb_tiny.png"
]
Objects.delete_multiple(keys)
"""
@spec delete_multiple([key()]) :: :ok
def delete_multiple(keys) do
run_all(fn opts ->
ExAws.S3.delete_multiple_objects(opts[:bucket], keys)
|> ExAws.request!(opts[:config_overrides])
end)
end
defp run_all(wrapped) do
fun = fn opts ->
try do
wrapped.(opts)
:ok
catch
_kind, _value -> :error
end
end
backends()
|> Task.async_stream(fun, timeout: :infinity)
|> Enum.any?(fn {_, v} -> v == :error end)
|> case do
true ->
Logger.warning("Failed to operate on all backends")
_ ->
:ok
end
:ok
end
defp backends do
primary_opts() ++ replica_opts()
end
defp primary_opts do
[
%{
config_overrides: Application.fetch_env!(:philomena, :s3_primary_options),
bucket: Application.fetch_env!(:philomena, :s3_primary_bucket)
}
]
end
defp replica_opts do
replica_bucket = Application.get_env(:philomena, :s3_secondary_bucket)
if not is_nil(replica_bucket) do
[
%{
config_overrides: Application.fetch_env!(:philomena, :s3_secondary_options),
bucket: replica_bucket
}
]
else
[]
end
end
end

View file

@ -0,0 +1,202 @@
defmodule PhilomenaMedia.Processors do
@moduledoc """
Utilities for processing uploads.
Processors have 4 functions available:
- `versions/1`:
Takes a version list and generates a list of files which the processor will generate
during the scope of `process/3`.
- `process/3`:
Takes an analysis result, file path, and version list and generates an "edit script" that
represents how to store this file according to the given version list. See
`m:Philomena.Images.Thumbnailer` for a usage example.
- `post_process/2`:
Takes an analysis result and file path and performs optimizations on the upload. See
`m:Philomena.Images.Thumbnailer` for a usage example.
- `intensities/2`:
Takes an analysis result and file path and generates corner intensities, performing.
any conversion necessary before processing. See `m:PhilomenaMedia.Intensities`
for more information.
## Version lists
`process/3` and `post_process/2` take _version lists_ as input. A version list is a structure
like the following, which contains pairs of _version names_ and _dimensions_:
[
thumb_tiny: {50, 50},
thumb_small: {150, 150},
thumb: {250, 250},
small: {320, 240},
medium: {800, 600},
large: {1280, 1024},
tall: {1024, 4096}
]
When calling these functions, it is recommended prefilter the version list based on the media
dimensions to avoid generating unnecessary versions which are larger than the original file.
See `m:Philomena.Images.Thumbnailer` for an example.
## Edit scripts
`process/3` and `post_process/2` return _edit scripts_. An edit script is a list where each
entry may be one of the following:
{:thumbnails, [copy_requests]}
{:replace_original, path}
{:intensities, intensities}
Within the thumbnail request, a copy request is defined with the following structure:
{:copy, path, version_filename}
See the respective functions for more information about their return values.
"""
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm}
alias PhilomenaMedia.Mime
# The name of a version, like :large
@type version_name :: atom()
@type dimensions :: {integer(), integer()}
@type version_list :: [{version_name(), dimensions()}]
# The file name of a processed version, like "large.png"
@type version_filename :: String.t()
# A single file to be copied to satisfy a request for a version name
@type copy_request :: {:copy, Path.t(), version_filename()}
# A list of thumbnail versions to copy into place
@type thumbnails :: {:thumbnails, [copy_request()]}
# Replace the original file to strip metadata or losslessly optimize
@type replace_original :: {:replace_original, Path.t()}
# Apply the computed corner intensities
@type intensities :: {:intensities, Intensities.t()}
# An edit script, representing the changes to apply to the storage backend
# after successful processing
@type edit_script :: [thumbnails() | replace_original() | intensities()]
@doc """
Returns a processor, with the processor being a module capable
of processing this content type, or nil.
The allowed MIME types are:
- `image/gif`
- `image/jpeg`
- `image/png`
- `image/svg+xml`
- `video/webm`
> #### Info {: .info}
>
> This is an interface intended for use when the MIME type is already known.
> Using a processor not matched to the file may cause unexpected results.
## Examples
iex> PhilomenaMedia.Processors.processor("image/png")
PhilomenaMedia.Processors.Png
iex> PhilomenaMedia.Processors.processor("application/octet-stream")
nil
"""
@spec processor(Mime.t()) :: module() | nil
def processor(content_type)
def processor("image/gif"), do: Gif
def processor("image/jpeg"), do: Jpeg
def processor("image/png"), do: Png
def processor("image/svg+xml"), do: Svg
def processor("video/webm"), do: Webm
def processor(_content_type), do: nil
@doc """
Takes a MIME type and filtered version list and generates a list of version files to be
generated by `process/2`. List contents may differ based on file type.
## Examples
iex> PhilomenaMedia.Processors.versions("image/png", [thumb_tiny: {50, 50}])
["thumb_tiny.png"]
iex> PhilomenaMedia.Processors.versions("video/webm", [thumb_tiny: {50, 50}])
["full.mp4", "rendered.png", "thumb_tiny.webm", "thumb_tiny.mp4", "thumb_tiny.gif"]
"""
@spec versions(Mime.t(), version_list()) :: [version_name()]
def versions(mime_type, valid_sizes) do
processor(mime_type).versions(valid_sizes)
end
@doc """
Takes an analyzer result, file path, and version list and runs the appropriate processor's
`process/3`, processing the media.
Returns an edit script to apply changes. Depending on the media type, this make take a long
time to execute.
## Example
iex> PhilomenaMedia.Processors.process(%Result{...}, "image.png", [thumb_tiny: {50, 50}])
[
intensities: %Intensities{...},
thumbnails: [
{:copy, "/tmp/briefly-5764/vSHsM3kn7k4yvrvZH.png", "thumb_tiny.png"}
]
]
"""
@spec process(Result.t(), Path.t(), version_list()) :: edit_script()
def process(analysis, file, versions) do
processor(analysis.mime_type).process(analysis, file, versions)
end
@doc """
Takes an analyzer result and file path and runs the appropriate processor's `post_process/2`,
performing long-running optimizations on the media source file.
Returns an edit script to apply changes. Depending on the media type, this make take a long
time to execute. This may also be an empty list, if there are no changes to perform.
## Example
iex> PhilomenaMedia.Processors.post_process(%Result{...}, "image.gif", [thumb_tiny: {50, 50}])
[replace_original: "/tmp/briefly-5764/cyZSQnmL59XDRoPoaDxr.gif"]
"""
@spec post_process(Result.t(), Path.t()) :: edit_script()
def post_process(analysis, file) do
processor(analysis.mime_type).post_process(analysis, file)
end
@doc """
Takes an analyzer result and file path and runs the appropriate processor's `intensities/2`,
returning the corner intensities.
This allows for generating intensities for file types that are not directly supported by
`m:PhilomenaMedia.Intensities`, and should be the preferred function to call when intensities
are needed.
## Example
iex> PhilomenaMedia.Processors.intensities(%Result{...}, "video.webm")
%Intensities{nw: 111.689148, ne: 116.228048, sw: 93.268433, se: 104.630064}
"""
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(analysis, file) do
processor(analysis.mime_type).intensities(analysis, file)
end
end

View file

@ -1,12 +1,21 @@
defmodule Philomena.Processors.Gif do
alias Philomena.Intensities
defmodule PhilomenaMedia.Processors.Gif do
@moduledoc false
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
@behaviour Processor
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
def versions(sizes) do
sizes
|> Enum.map(fn {name, _} -> "#{name}.gif" end)
|> Kernel.++(["full.webm", "full.mp4", "rendered.png"])
end
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
def process(analysis, file, versions) do
duration = analysis.duration
preview = preview(duration, file)
@ -17,16 +26,18 @@ defmodule Philomena.Processors.Gif do
scaled = Enum.flat_map(versions, &scale(palette, file, &1))
videos = generate_videos(file)
%{
[
intensities: intensities,
thumbnails: scaled ++ videos ++ [{:copy, preview, "rendered.png"}]
}
]
end
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
def post_process(_analysis, file) do
%{replace_original: optimize(file)}
[replace_original: optimize(file)]
end
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(analysis, file) do
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
intensities

View file

@ -1,10 +1,19 @@
defmodule Philomena.Processors.Jpeg do
alias Philomena.Intensities
defmodule PhilomenaMedia.Processors.Jpeg do
@moduledoc false
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
@behaviour Processor
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
def versions(sizes) do
Enum.map(sizes, fn {name, _} -> "#{name}.jpg" end)
end
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
def process(_analysis, file, versions) do
stripped = optimize(strip(file))
@ -12,15 +21,17 @@ defmodule Philomena.Processors.Jpeg do
scaled = Enum.flat_map(versions, &scale(stripped, &1))
%{
[
replace_original: stripped,
intensities: intensities,
thumbnails: scaled
}
]
end
def post_process(_analysis, _file), do: %{}
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
def post_process(_analysis, _file), do: []
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(_analysis, file) do
{:ok, intensities} = Intensities.file(file)
intensities

View file

@ -1,10 +1,19 @@
defmodule Philomena.Processors.Png do
alias Philomena.Intensities
defmodule PhilomenaMedia.Processors.Png do
@moduledoc false
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
@behaviour Processor
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
def versions(sizes) do
Enum.map(sizes, fn {name, _} -> "#{name}.png" end)
end
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
def process(analysis, file, versions) do
animated? = analysis.animated?
@ -12,21 +21,23 @@ defmodule Philomena.Processors.Png do
scaled = Enum.flat_map(versions, &scale(file, animated?, &1))
%{
[
intensities: intensities,
thumbnails: scaled
}
]
end
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
def post_process(analysis, file) do
if analysis.animated? do
# libpng has trouble with animations, so skip optimization
%{}
[]
else
%{replace_original: optimize(file)}
[replace_original: optimize(file)]
end
end
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(_analysis, file) do
{:ok, intensities} = Intensities.file(file)
intensities

View file

@ -0,0 +1,21 @@
defmodule PhilomenaMedia.Processors.Processor do
@moduledoc false
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors
alias PhilomenaMedia.Intensities
# Generate a list of version filenames for the given version list.
@callback versions(Processors.version_list()) :: [Processors.version_filename()]
# Process the media at the given path against the given version list, and return an
# edit script with the resulting files
@callback process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
# Perform post-processing optimization tasks on the file, to reduce its size
# and strip non-essential metadata
@callback post_process(Result.t(), Path.t()) :: Processors.edit_script()
# Generate corner intensities for the given path
@callback intensities(Result.t(), Path.t()) :: Intensities.t()
end

View file

@ -1,12 +1,21 @@
defmodule Philomena.Processors.Svg do
alias Philomena.Intensities
defmodule PhilomenaMedia.Processors.Svg do
@moduledoc false
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
@behaviour Processor
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
def versions(sizes) do
sizes
|> Enum.map(fn {name, _} -> "#{name}.png" end)
|> Kernel.++(["rendered.png", "full.png"])
end
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
def process(_analysis, file, versions) do
preview = preview(file)
@ -15,14 +24,16 @@ defmodule Philomena.Processors.Svg do
scaled = Enum.flat_map(versions, &scale(preview, &1))
full = [{:copy, preview, "full.png"}]
%{
[
intensities: intensities,
thumbnails: scaled ++ full ++ [{:copy, preview, "rendered.png"}]
}
]
end
def post_process(_analysis, _file), do: %{}
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
def post_process(_analysis, _file), do: []
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(_analysis, file) do
{:ok, intensities} = Intensities.file(preview(file))
intensities

View file

@ -1,7 +1,15 @@
defmodule Philomena.Processors.Webm do
alias Philomena.Intensities
defmodule PhilomenaMedia.Processors.Webm do
@moduledoc false
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
import Bitwise
@behaviour Processor
@spec versions(Processors.version_list()) :: [Processors.version_filename()]
def versions(sizes) do
webm_versions = Enum.map(sizes, fn {name, _} -> "#{name}.webm" end)
mp4_versions = Enum.map(sizes, fn {name, _} -> "#{name}.mp4" end)
@ -14,6 +22,7 @@ defmodule Philomena.Processors.Webm do
["full.mp4", "rendered.png"] ++ webm_versions ++ mp4_versions ++ gif_versions
end
@spec process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
def process(analysis, file, versions) do
dimensions = analysis.dimensions
duration = analysis.duration
@ -27,15 +36,17 @@ defmodule Philomena.Processors.Webm do
scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1))
mp4 = [{:copy, mp4, "full.mp4"}]
%{
[
replace_original: stripped,
intensities: intensities,
thumbnails: scaled ++ mp4 ++ [{:copy, preview, "rendered.png"}]
}
]
end
def post_process(_analysis, _file), do: %{}
@spec post_process(Result.t(), Path.t()) :: Processors.edit_script()
def post_process(_analysis, _file), do: []
@spec intensities(Result.t(), Path.t()) :: Intensities.t()
def intensities(analysis, file) do
{:ok, intensities} = Intensities.file(preview(analysis.duration, file))
intensities

View file

@ -1,6 +1,21 @@
defmodule Philomena.Sha512 do
defmodule PhilomenaMedia.Sha512 do
@moduledoc """
Streaming SHA-512 processor.
"""
@chunk_size 10_485_760
@doc """
Generate the SHA2-512 hash of the file at the given path as a string.
The file is processed in 10MiB chunks.
## Example
iex> Sha512.file("image.png")
"97fd5243cd39e225f1478097acae71fbbff7f3027b24f0e6a8e06a0d7d3e6861cd05691d7470c76e7dfc4eb30459a906918d5ba0d144184fff02b8e34bd9ecf8"
"""
@spec file(Path.t()) :: String.t()
def file(path) do
hash_ref = :crypto.hash_init(:sha512)

View file

@ -0,0 +1,360 @@
defmodule PhilomenaMedia.Uploader do
@moduledoc """
Upload and processing callback logic for media files.
To use the uploader, the target schema must be modified to add at least the
following fields, assuming the name of the field to write to the database is `foo`:
field :foo, :string
field :uploaded_foo, :string, virtual: true
field :removed_foo, :string, virtual: true
The schema should also define a changeset function which casts the file parameters. This may be
the default changeset function, or a function specialized to accept only the file parameters. A
minimal schema must cast at least the following to successfully upload and replace files:
def foo_changeset(schema, attrs) do
cast(schema, attrs, [:foo, :uploaded_foo, :removed_foo])
end
Additional fields may be added to perform validations. For example, specifying a field name
`foo_mime_type` allows the creation of a MIME type filter in the changeset:
def foo_changeset(schema, attrs) do
schema
|> cast(attrs, [:foo, :foo_mime_type, :uploaded_foo, :removed_foo])
|> validate_required([:foo, :foo_mime_type])
|> validate_inclusion(:foo_mime_type, ["image/svg+xml"])
end
See `analyze_upload/4` for more information about what fields may be validated in this
fashion.
Generally, you should expect to create a `Schemas.Uploader` module, which defines functions as
follows, pointing to `m:PhilomenaMedia.Uploader`. Assuming the target field name is `"foo"`, then:
defmodule Philomena.Schemas.Uploader do
alias Philomena.Schemas.Schema
alias PhilomenaMedia.Uploader
@field_name "foo"
def analyze_upload(schema, params) do
Uploader.analyze_upload(schema, @field_name, params[@field_name], &Schema.foo_changeset/2)
end
def persist_upload(schema) do
Uploader.persist_upload(schema, schema_file_root(), @field_name)
end
def unpersist_old_upload(schema) do
Uploader.unpersist_old_upload(schema, schema_file_root(), @field_name)
end
defp schema_file_root do
Application.get_env(:philomena, :schema_file_root)
end
end
A typical context usage may then look like:
alias Philomena.Schemas.Schema
alias Philomena.Schemas.Uploader
@spec create_schema(map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()}
def create_schema(attrs) do
%Schema{}
|> Uploader.analyze_upload(attrs)
|> Repo.insert()
|> case do
{:ok, schema} ->
Uploader.persist_upload(schema)
{:ok, schema}
error ->
error
end
end
@spec update_schema(Schema.t(), map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()}
def update_schema(%Schema{} = schema, attrs) do
schema
|> Uploader.analyze_upload(attrs)
|> Repo.update()
|> case do
{:ok, schema} ->
Uploader.persist_upload(schema)
Uploader.unpersist_old_upload(schema)
{:ok, schema}
error ->
error
end
end
This forwards to the core `m:PhilomenaMedia.Uploader` logic with information about the file root.
The file root is the location at which files of the given schema type are located under
the storage path. For example, the file root for the Adverts schema may be
`/srv/philomena/priv/s3/philomena/adverts` in development with the file backend,
and just `adverts` in production with the S3 backend.
It is not recommended to perform persist or unpersist operations in the scope of an `m:Ecto.Multi`,
as they may block indefinitely.
"""
alias PhilomenaMedia.Analyzers
alias PhilomenaMedia.Filename
alias PhilomenaMedia.Objects
alias PhilomenaMedia.Sha512
import Ecto.Changeset
@type schema :: struct()
@type schema_or_changeset :: struct() | Ecto.Changeset.t()
@type field_name :: String.t()
@type file_root :: String.t()
@doc """
Performs analysis of the specified `m:Plug.Upload`, and invokes a changeset callback on the schema
or changeset passed in.
The file name which will be written to is set by the assignment to the schema's `field_name`, and
the below attributes are prefixed by the `field_name`.
Assuming the file is successfully parsed, this will attempt to cast the following
attributes into the specified changeset function:
* `name` (String) - the name of the file
* `width` (integer) - the width of the file
* `height` (integer) - the height of the file
* `size` (integer) - the size of the file, in bytes
* `format` (String) - the file extension, one of `~w(gif jpg png svg webm)`, determined by reading the file
* `mime_type` (String) - the file's sniffed MIME type, determined by reading the file
* `duration` (float) - the duration of the media file
* `aspect_ratio` (float) - width divided by height.
* `orig_sha512_hash` (String) - the SHA-512 hash of the file
* `sha512_hash` (String) - the SHA-512 hash of the file
* `is_animated` (boolean) - whether the file contains animation
You may design your changeset callback to accept any of these. Here is an example which accepts
all of them:
def foo_changeset(schema, attrs)
cast(schema, attrs, [
:foo,
:foo_name,
:foo_width,
:foo_height,
:foo_size,
:foo_format,
:foo_mime_type,
:foo_duration,
:foo_aspect_ratio,
:foo_orig_sha512_hash,
:foo_sha512_hash,
:foo_is_animated,
:uploaded_foo,
:removed_foo
])
end
Attributes are prefixed, so assuming a `field_name` of `"foo"`, this would result in
the changeset function receiving attributes `"foo_name"`, `"foo_width"`, ... etc.
Validations on the uploaded media are also possible in the changeset callback. For example,
`m:Philomena.Adverts.Advert` performs validations on MIME type and width of its field, named
`image`:
def image_changeset(advert, attrs) do
advert
|> cast(attrs, [
:image,
:image_mime_type,
:image_size,
:image_width,
:image_height,
:uploaded_image,
:removed_image
])
|> validate_required([:image])
|> validate_inclusion(:image_mime_type, ["image/png", "image/jpeg", "image/gif"])
|> validate_inclusion(:image_width, 699..729)
end
The key (location to write the persisted file) is passed with the `field_name` attribute into the
changeset callback. The key is calculated using the current date, a UUID, and the computed
extension. A file uploaded may therefore be given a key such as
`2024/1/1/0bce8eea-17e0-11ef-b7d4-0242ac120006.png`. See `PhilomenaMedia.Filename.build/1` for
the actual construction.
This function does not persist an upload to storage.
See the module documentation for a complete example.
## Example
@spec analyze_upload(Uploader.schema_or_changeset(), map()) :: Ecto.Changeset.t()
def analyze_upload(schema, params) do
Uploader.analyze_upload(schema, "foo", params["foo"], &Schema.foo_changeset/2)
end
"""
@spec analyze_upload(
schema_or_changeset(),
field_name(),
Plug.Upload.t(),
(schema_or_changeset(), map() -> Ecto.Changeset.t())
) :: Ecto.Changeset.t()
def analyze_upload(schema_or_changeset, field_name, upload_parameter, changeset_fn) do
with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
analysis <- extra_attributes(analysis, upload_parameter) do
removed =
schema_or_changeset
|> change()
|> get_field(field(field_name))
attributes =
%{
"name" => analysis.name,
"width" => analysis.width,
"height" => analysis.height,
"size" => analysis.size,
"format" => analysis.extension,
"mime_type" => analysis.mime_type,
"duration" => analysis.duration,
"aspect_ratio" => analysis.aspect_ratio,
"orig_sha512_hash" => analysis.sha512,
"sha512_hash" => analysis.sha512,
"is_animated" => analysis.animated?
}
|> prefix_attributes(field_name)
|> Map.put(field_name, analysis.new_name)
|> Map.put(upload_key(field_name), upload_parameter.path)
|> Map.put(remove_key(field_name), removed)
changeset_fn.(schema_or_changeset, attributes)
else
{:unsupported_mime, mime} ->
attributes = prefix_attributes(%{"mime_type" => mime}, field_name)
changeset_fn.(schema_or_changeset, attributes)
_error ->
changeset_fn.(schema_or_changeset, %{})
end
end
@doc """
Writes the file to permanent storage. This should be the second-to-last step
before completing a file operation.
The key (location to write the persisted file) is fetched from the schema by `field_name`.
This is then prefixed with the `file_root` specified by the caller. Finally, the file is
written to storage.
See the module documentation for a complete example.
## Example
@spec persist_upload(Schema.t()) :: :ok
def persist_upload(schema) do
Uploader.persist_upload(schema, schema_file_root(), "foo")
end
"""
@spec persist_upload(schema(), file_root(), field_name()) :: :ok
def persist_upload(schema, file_root, field_name) do
source = Map.get(schema, field(upload_key(field_name)))
dest = Map.get(schema, field(field_name))
target = Path.join(file_root, dest)
persist_file(target, source)
end
@doc """
Persist an arbitrary file to storage with the given key.
> #### Warning {: .warning}
>
> This is exposed for schemas which do not store their files at at an offset from a file root,
> to allow overriding the key. If you do not need to override the key, use
> `persist_upload/3` instead.
The key (location to write the persisted file) and the file path to upload are passed through
to `PhilomenaMedia.Objects.upload/2` without modification. See the definition of that function for
additional details.
## Example
key = "2024/1/1/5/full.png"
Uploader.persist_file(key, file_path)
"""
@spec persist_file(Objects.key(), Path.t()) :: :ok
def persist_file(key, file_path) do
Objects.upload(key, file_path)
end
@doc """
Removes the old file from permanent storage. This should be the last step in
completing a file operation.
The key (location to write the persisted file) is fetched from the schema by `field_name`.
This is then prefixed with the `file_root` specified by the caller. Finally, the file is
purged from storage.
See the module documentation for a complete example.
## Example
@spec unpersist_old_upload(Schema.t()) :: :ok
def unpersist_old_upload(schema) do
Uploader.unpersist_old_upload(schema, schema_file_root(), "foo")
end
"""
@spec unpersist_old_upload(schema(), file_root(), field_name()) :: :ok
def unpersist_old_upload(schema, file_root, field_name) do
schema
|> Map.get(field(remove_key(field_name)))
|> try_remove(file_root)
end
defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do
{width, height} = analysis.dimensions
aspect_ratio = aspect_ratio(width, height)
stat = File.stat!(path)
sha512 = Sha512.file(path)
new_name = Filename.build(analysis.extension)
analysis
|> Map.put(:size, stat.size)
|> Map.put(:name, filename)
|> Map.put(:width, width)
|> Map.put(:height, height)
|> Map.put(:sha512, sha512)
|> Map.put(:new_name, new_name)
|> Map.put(:aspect_ratio, aspect_ratio)
end
defp aspect_ratio(_, 0), do: 0.0
defp aspect_ratio(w, h), do: w / h
defp try_remove("", _file_root), do: :ok
defp try_remove(nil, _file_root), do: :ok
defp try_remove(file, file_root) do
Objects.delete(Path.join(file_root, file))
end
defp prefix_attributes(map, prefix),
do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end)
defp upload_key(field_name), do: "uploaded_#{field_name}"
defp remove_key(field_name), do: "removed_#{field_name}"
defp field(field_name), do: String.to_existing_atom(field_name)
end

View file

@ -1,6 +1,6 @@
defmodule PhilomenaWeb.ImageReverse do
alias Philomena.Analyzers
alias Philomena.Processors
alias PhilomenaMedia.Analyzers
alias PhilomenaMedia.Processors
alias Philomena.DuplicateReports
alias Philomena.Repo
import Ecto.Query