2024-05-22 13:17:21 +12:00
|
|
|
defmodule Podbox.Download.Storage do
|
|
|
|
@moduledoc """
|
|
|
|
Responsible for storing files on the local filesystem.
|
|
|
|
"""
|
|
|
|
|
|
|
|
defstruct [:asset, :complete_path, :complete_root, :incomplete_root, :incomplete_path, :io]
|
2024-05-28 12:11:37 +12:00
|
|
|
alias Podbox.{Download.Asset, Download.InvalidStateError, Download.PosixError}
|
2024-05-22 13:17:21 +12:00
|
|
|
|
|
|
|
@type t :: %__MODULE__{
|
|
|
|
asset: nil | Asset.t(),
|
|
|
|
complete_path: nil | Path.t(),
|
|
|
|
complete_root: nil | Path.t(),
|
|
|
|
incomplete_root: nil | Path.t(),
|
|
|
|
incomplete_path: nil | Path.t(),
|
|
|
|
io: nil | File.io_device()
|
|
|
|
}
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Initialise storage for a specific asset
|
|
|
|
"""
|
|
|
|
@spec init(Asset.t(), Keyword.t()) :: t
|
|
|
|
def init(asset, opts \\ []) do
|
|
|
|
config =
|
|
|
|
storage_config()
|
|
|
|
|> Map.merge(Map.new(opts))
|
|
|
|
|
|
|
|
incomplete_path = generate_subpath(config.incomplete_root, asset.id)
|
|
|
|
complete_path = generate_subpath(config.complete_root, asset.id)
|
|
|
|
|
|
|
|
%__MODULE__{
|
|
|
|
asset: asset,
|
|
|
|
complete_path: complete_path,
|
|
|
|
complete_root: config.complete_root,
|
|
|
|
incomplete_path: incomplete_path,
|
|
|
|
incomplete_root: config.incomplete_root
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Checks and returns the storage state.
|
|
|
|
"""
|
|
|
|
@spec status(t) ::
|
|
|
|
:ok | :error | {:complete, non_neg_integer()} | {:incomplete, non_neg_integer()}
|
|
|
|
def status(storage) do
|
|
|
|
complete_exists? = File.exists?(storage.complete_path)
|
|
|
|
incomplete_exists? = File.exists?(storage.incomplete_path)
|
|
|
|
|
|
|
|
cond do
|
|
|
|
complete_exists? && incomplete_exists? -> :error
|
|
|
|
complete_exists? -> {:complete, File.stat!(storage.complete_path).size}
|
|
|
|
incomplete_exists? -> {:incomplete, File.stat!(storage.incomplete_path).size}
|
|
|
|
true -> :ok
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Ensures that the directory needed to store the partial asset is present, and
|
|
|
|
ready to be written to.
|
|
|
|
"""
|
|
|
|
@spec prepare(t) :: {:ok, t} | {:error, any}
|
|
|
|
def prepare(storage) do
|
|
|
|
incomplete_dir = Path.dirname(storage.incomplete_path)
|
|
|
|
|
|
|
|
with :ok <- handle_posix_failure(incomplete_dir, &File.mkdir_p/1, :mkdir),
|
|
|
|
{:ok, io} <-
|
|
|
|
handle_posix_failure(
|
|
|
|
storage.incomplete_path,
|
|
|
|
&File.open(&1, [:write]),
|
|
|
|
{:open, [:write]}
|
|
|
|
) do
|
|
|
|
{:ok, %{storage | io: io}}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Write bytes to an incomplete file.
|
|
|
|
"""
|
|
|
|
@spec append(t, binary) :: {:ok, t} | {:error, any}
|
|
|
|
def append(storage, bytes) when not is_nil(storage.io) do
|
|
|
|
IO.binwrite(storage.io, bytes)
|
|
|
|
{:ok, storage}
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
All contents are written, now move the file to complete.
|
|
|
|
"""
|
|
|
|
@spec commit(t) :: {:ok, t} | {:error, any}
|
|
|
|
def commit(storage) do
|
|
|
|
complete_dir = Path.dirname(storage.complete_path)
|
|
|
|
|
|
|
|
with :ok <- handle_posix_failure(complete_dir, &File.mkdir_p/1, :mkdir),
|
|
|
|
:ok <-
|
|
|
|
handle_posix_failure(
|
|
|
|
storage.incomplete_path,
|
|
|
|
fn _ -> File.close(storage.io) end,
|
|
|
|
:close
|
|
|
|
),
|
|
|
|
:ok <-
|
|
|
|
handle_posix_failure(
|
|
|
|
storage.incomplete_path,
|
|
|
|
&File.rename(&1, storage.complete_path),
|
|
|
|
{:move, to: storage.complete_path}
|
|
|
|
),
|
|
|
|
:ok <- prune(storage.incomplete_path, storage.incomplete_root) do
|
|
|
|
{:ok, %{storage | io: nil}}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Remove all artefacts from the filesystem.
|
|
|
|
"""
|
|
|
|
@spec destroy(t) :: :ok | {:error, any}
|
|
|
|
def destroy(storage) when is_nil(storage.io) do
|
|
|
|
with :ok <- prune(storage.incomplete_path, storage.incomplete_root) do
|
|
|
|
prune(storage.complete_path, storage.complete_root)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def destroy(storage) do
|
|
|
|
with :ok <- File.close(storage.io) do
|
|
|
|
destroy(%{storage | io: nil})
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-28 12:11:37 +12:00
|
|
|
@doc """
|
|
|
|
Read completed file contents.
|
|
|
|
"""
|
|
|
|
@spec read_completed(t) :: {:ok, binary} | {:error, any}
|
|
|
|
def read_completed(storage) do
|
|
|
|
with :ok <- assert_complete(storage) do
|
|
|
|
handle_posix_failure(storage.complete_path, &File.read/1, :read)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp assert_complete(storage) do
|
|
|
|
case status(storage) do
|
|
|
|
{:complete, _} ->
|
|
|
|
:ok
|
|
|
|
|
|
|
|
{:incomplete, _} ->
|
|
|
|
{:error,
|
|
|
|
InvalidStateError.exception(
|
|
|
|
asset_id: storage.asset.id,
|
|
|
|
current_state: :incomplete,
|
|
|
|
expected_state: :complete
|
|
|
|
)}
|
|
|
|
|
|
|
|
:error ->
|
|
|
|
{:error,
|
|
|
|
InvalidStateError.exception(
|
|
|
|
asset_id: storage.asset.id,
|
|
|
|
current_state: :error,
|
|
|
|
expected_state: :complete
|
|
|
|
)}
|
|
|
|
|
|
|
|
:ok ->
|
|
|
|
{:error,
|
|
|
|
InvalidStateError.exception(
|
|
|
|
asset_id: storage.asset.id,
|
|
|
|
current_state: :none,
|
|
|
|
expected_state: :complete
|
|
|
|
)}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-22 13:17:21 +12:00
|
|
|
defp generate_subpath(
|
|
|
|
root,
|
|
|
|
<<a::binary-size(1), b::binary-size(1), c::binary-size(1), _::binary>> = id
|
|
|
|
) do
|
|
|
|
Path.join(root, [a, b, c, id])
|
|
|
|
end
|
|
|
|
|
|
|
|
defp storage_config do
|
|
|
|
config =
|
|
|
|
:podbox
|
|
|
|
|> Application.get_env(__MODULE__, [])
|
|
|
|
|
|
|
|
%{
|
|
|
|
complete_root: Keyword.fetch!(config, :complete),
|
|
|
|
incomplete_root: Keyword.fetch!(config, :incomplete)
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
defp handle_posix_failure(path, callback, activity) when is_function(callback, 1) do
|
|
|
|
with {:error, posix} <- callback.(path) do
|
|
|
|
{:error, PosixError.exception(file_path: path, posix: posix, activity: activity)}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp prune(path, down_to) when is_binary(path) do
|
|
|
|
down_to_count =
|
|
|
|
down_to
|
|
|
|
|> Path.split()
|
|
|
|
|> Enum.count()
|
|
|
|
|
|
|
|
to_prune =
|
|
|
|
path
|
|
|
|
|> Path.split()
|
|
|
|
|> Enum.drop(down_to_count)
|
|
|
|
|> Enum.reverse()
|
|
|
|
|
|
|
|
prune(to_prune, down_to)
|
|
|
|
end
|
|
|
|
|
|
|
|
defp prune([], _down_to), do: :ok
|
|
|
|
|
|
|
|
defp prune([_ | remaining] = to_prune, down_to) do
|
|
|
|
path =
|
|
|
|
to_prune
|
|
|
|
|> Enum.reverse()
|
|
|
|
|> then(&[down_to | &1])
|
|
|
|
|> Path.join()
|
|
|
|
|
|
|
|
cond do
|
|
|
|
File.dir?(path) ->
|
|
|
|
case File.rmdir(path) do
|
|
|
|
:ok ->
|
|
|
|
prune(remaining, down_to)
|
|
|
|
|
|
|
|
{:error, :eexist} ->
|
|
|
|
:ok
|
|
|
|
|
|
|
|
{:error, posix} ->
|
|
|
|
{:error, PosixError.exception(file_path: path, posix: posix, activity: :delete)}
|
|
|
|
end
|
|
|
|
|
|
|
|
File.exists?(path) ->
|
|
|
|
with :ok <- handle_posix_failure(path, &File.rm/1, :delete) do
|
|
|
|
prune(remaining, down_to)
|
|
|
|
end
|
|
|
|
|
|
|
|
true ->
|
|
|
|
prune(remaining, down_to)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|