mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-12-05 00:57:59 +01:00
361 lines
12 KiB
Elixir
361 lines
12 KiB
Elixir
|
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
|