Packages

phoenix_kit

2.41.2
2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web attachments.ex
Raw

lib/phoenix_kit_web/attachments.ex

defmodule PhoenixKitWeb.Attachments do
@moduledoc """
The pieces every module's file form shares, so each upload behaves the
same wherever it happens: the upload config, storing a finished upload
into a record's folder, the messages people see, and the files grid's
featured-image and order rules.
socket = Attachments.allow(socket, :attachment_files, &handle_progress/3)
def handle_progress(:attachment_files, %{done?: true} = entry, socket) do
stored =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
{:ok, Attachments.store(path, entry, Actor.uuid(socket), folder_uuid)}
end)
case stored do
{:ok, _file} -> …
{:already_attached, existing} -> put_flash(socket, :info, Attachments.duplicate_notice(entry.client_name, existing))
{:error, reason} -> put_flash(socket, :error, Attachments.failed_message(entry.client_name, reason))
end
end
What each form keeps — which folder, when its pointers are written, who
hears about a change — is its own; the folder rules underneath are
`PhoenixKit.Modules.Storage.ResourceFolders`.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.ResourceFolders
alias PhoenixKit.Users.Auth
@defaults [
accept: :any,
max_entries: 20,
max_file_size: 100_000_000,
auto_upload: true
]
@doc """
Registers upload `name` with the attachment defaults — any file type,
20 files, 100 MB each, uploaded as soon as they are chosen — and
`progress` as its progress callback. `opts` override the defaults.
"""
@spec allow(Phoenix.LiveView.Socket.t(), atom(), function(), keyword()) ::
Phoenix.LiveView.Socket.t()
def allow(socket, name, progress, opts \\ []) when is_atom(name) and is_function(progress, 3) do
Phoenix.LiveView.allow_upload(
socket,
name,
@defaults |> Keyword.merge(opts) |> Keyword.put(:progress, progress)
)
end
@doc """
Stores a finished upload (the temp `path` of `entry`) for `user_uuid`
and files it into `folder_uuid` by core's rules
(`ResourceFolders.place_stored/2`): a content duplicate already in the
folder is `{:already_attached, file}`, a trashed one is restored. The
browser's file name is reduced to its base name before it reaches
storage, and the type comes from `Storage.determine_file_type/2`.
`nil` for the folder stores the file without placing it (a form that
files it on save); a trashed duplicate is restored then too, so the
file returned is always live. Never raises — call it outside a
transaction, which a database error would abort.
"""
@spec store(String.t(), map(), String.t() | nil, String.t() | nil) ::
{:ok, Storage.File.t()} | {:already_attached, Storage.File.t()} | {:error, term()}
def store(_path, _entry, nil, _folder_uuid), do: {:error, :no_user}
def store(path, entry, user_uuid, folder_uuid) do
name = client_name(entry)
mime = client_type(entry)
ext = name |> Path.extname() |> String.trim_leading(".") |> String.downcase()
hash = Auth.calculate_file_hash(path)
path
|> Storage.store_file_in_buckets(
Storage.determine_file_type(mime, name),
user_uuid,
hash,
ext,
name,
mime_type: mime
)
|> place(folder_uuid)
rescue
error ->
Logger.warning("Storing an upload failed: #{ResourceFolders.describe_failure(error)}")
{:error, error}
catch
:exit, reason ->
Logger.warning(
"Storing an upload failed: #{ResourceFolders.describe_failure({:exit, reason})}"
)
{:error, {:exit, reason}}
end
defp place({:ok, file}, nil), do: {:ok, file}
# Re-uploading a file that was trashed means it is wanted again — as
# `place_stored/2` does when there is a folder. A form that files on save
# would otherwise stage the trashed row and then fail to attach it.
# Into no folder: it is staged, and the folder it was removed from must not
# show it again.
defp place({:ok, %{status: "trashed"} = file, :duplicate}, nil) do
case Storage.restore_file_into(file, nil) do
{:ok, file} -> {:ok, file}
# Restored by someone else first: it keeps the home they gave it.
{:error, :not_trashed} -> {:ok, Storage.get_file(file.uuid) || file}
end
end
defp place({:ok, file, :duplicate}, nil), do: {:ok, file}
defp place(stored, folder_uuid) when is_binary(folder_uuid),
do: ResourceFolders.place_stored(stored, folder_uuid)
defp place({:error, reason}, _folder_uuid), do: {:error, reason}
# `client_name` is the browser's and only checked against `:accept`: a path
# (either separator), control characters (a NUL fails the insert) and a
# bare "." or ".." never reach storage as a file name, and the name fits
# the 255-character column with its extension kept.
@max_name 255
defp client_name(entry) do
name =
entry
|> Map.get(:client_name)
|> to_string()
|> String.replace(~r/[\x00-\x1F\x7F]/u, "")
|> String.split(["/", "\\"])
|> List.last()
if name in ["", ".", ".."], do: "upload", else: bounded(name)
end
# Also the browser's: kept only when it reads as a mime type (parameters
# dropped, case folded — `IMAGE/PNG` is the same type, and storage
# classifies by a lower-case prefix), so a long or garbage one cannot
# fail the insert — storage then guesses from the name instead.
@mime ~r/\A[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\z/i
defp client_type(entry) do
type =
entry
|> Map.get(:client_type)
|> to_string()
|> String.split(";")
|> hd()
|> String.trim()
|> String.downcase()
if Regex.match?(@mime, type), do: type
end
# The column counts code points, not graphemes (an emoji can be several).
defp bounded(name) do
points = String.codepoints(name)
if length(points) <= @max_name do
name
else
ext = name |> Path.extname() |> String.codepoints() |> Enum.take(16)
Enum.join(Enum.take(points, @max_name - length(ext)) ++ ext)
end
end
@doc "What to tell a person about an upload error (LiveView's or `store/4`'s)."
@spec error_message(term()) :: String.t()
def error_message(:too_large), do: gettext("File is too large.")
def error_message(:not_accepted), do: gettext("File type not accepted.")
def error_message(:too_many_files), do: gettext("Too many files.")
def error_message(:no_user), do: gettext("Sign in to upload files.")
def error_message(other),
do: gettext("Upload error: %{reason}", reason: ResourceFolders.describe_failure(other))
@doc "What to tell a person when their upload `client_name` could not be stored."
@spec failed_message(String.t(), term()) :: String.t()
def failed_message(_client_name, :no_user), do: error_message(:no_user)
def failed_message(client_name, _reason),
do: gettext("Upload failed for %{name}.", name: Path.basename(to_string(client_name)))
@doc """
What to tell a person whose upload is byte-identical to a file already
in the folder: storage de-duplicates by content, so nothing new appears
and the file keeps the earlier upload's name — without this it reads as
a lost file.
"""
@spec duplicate_notice(String.t(), map()) :: String.t()
def duplicate_notice(client_name, existing) do
name = Path.basename(to_string(client_name))
gettext("%{name} is identical to %{existing}, which is already attached — nothing was added.",
name: name,
existing: Map.get(existing, :original_file_name) || name
)
end
@doc "What to tell a person when a record's files folder cannot be prepared."
@spec folder_error_message() :: String.t()
def folder_error_message, do: gettext("Could not prepare the files folder.")
@doc """
A folder's files with the record's featured image first when it lives
elsewhere — a featured image moved out of the folder is still what the
record shows, so the grid shows it too.
"""
@spec with_featured([map()], map() | nil) :: [map()]
def with_featured(files, nil), do: files
def with_featured(files, %{uuid: uuid} = featured) do
if Enum.any?(files, &(&1.uuid == uuid)), do: files, else: [featured | files]
end
@doc """
Files sorted by a saved order of uuids: files the order doesn't know
keep their relative place after the ordered ones (new uploads land
last), and an empty order changes nothing.
"""
@spec apply_order([map()], [String.t()] | nil) :: [map()]
def apply_order(files, order) when is_list(order) and order != [] do
index = order |> Enum.with_index() |> Map.new()
tail = length(order)
files
|> Enum.with_index()
|> Enum.sort_by(fn {file, position} ->
{Map.get(index, to_string(file.uuid), tail), position}
end)
|> Enum.map(&elem(&1, 0))
end
def apply_order(files, _order), do: files
end