wiki/lib/wiki/content/upload.ex

159 lines
4.2 KiB
Elixir

defmodule Wiki.Content.Upload do
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{
relative_path: String.t(),
content_type: String.t(),
encryption_iv: binary(),
page: Wiki.Content.Page.t()
}
schema "uploads" do
field :relative_path, :string
field :content_type, :string
field :encryption_iv, :binary
belongs_to :page, Wiki.Content.Page
timestamps()
end
@doc false
def changeset(upload, attrs) do
upload
|> cast(attrs, [:relative_path, :content_type, :encryption_iv, :page_id])
|> validate_required([:relative_path, :content_type, :encryption_iv])
end
@spec create_from_plug(upload :: Plug.Upload.t(), key :: binary()) :: t()
def create_from_plug(upload, key) do
{encrypted_file_path, iv} = encrypt_contents(upload, key)
changeset(%__MODULE__{}, %{
content_type: upload.content_type,
relative_path: encrypted_file_path,
encryption_iv: iv
})
end
@spec encrypt_contents(upload :: Plug.Upload.t(), key :: binary()) :: {String.t(), binary()}
defp encrypt_contents(upload, key) do
filename = "#{Ecto.UUID.generate()}.enc"
output_path = Path.join(upload_dir(), filename)
File.mkdir_p(upload_dir())
:ok = File.touch(output_path)
{:ok, input} = File.open(upload.path, [:read, :binary])
{:ok, output} = File.open(output_path, [:write, :binary])
iv = :crypto.strong_rand_bytes(16)
state = :crypto.crypto_init(:aes_256_ctr, key, iv, true)
:ok = encrypt_loop(state, input, output)
final_data = :crypto.crypto_final(state)
:ok = IO.binwrite(output, final_data)
:ok = File.close(input)
:ok = File.close(output)
{filename, iv}
end
@spec encrypt_loop(state :: :crypto.crypto_state(), input :: IO.device(), output :: IO.device()) ::
:ok
defp encrypt_loop(state, input, output) do
# why 4k?
case IO.binread(input, 4096) do
:eof ->
:ok
data ->
encrypted_data = :crypto.crypto_update(state, data)
:ok = IO.binwrite(output, encrypted_data)
encrypt_loop(state, input, output)
end
end
@spec upload_dir() :: String.t()
def upload_dir() do
dir = Application.get_env(:wiki, :upload_dir)
if is_nil(dir) do
raise "expected `:wiki, :upload_dir` to be configured"
end
dir
end
@spec decrypt_content(upload :: t(), key :: binary()) :: binary()
def decrypt_content(%__MODULE__{relative_path: filename, encryption_iv: iv}, key) do
path = Path.join(upload_dir(), filename)
encrypted_data = File.read!(path)
:crypto.crypto_one_time(:aes_256_ctr, key, iv, encrypted_data, false)
end
@spec decrypt_to_file(t(), binary(), String.t()) :: File.t()
def decrypt_to_file(upload, key, path) when is_binary(path) do
{:ok, output} = File.open(path, [:write, :binary])
:ok = decrypt_to_file(upload, key, output)
File.close(output)
end
@spec decrypt_to_file(t(), binary(), File.io_device()) :: File.t()
def decrypt_to_file(upload, key, output) do
decrypt_stream(upload, key)
|> Enum.each(fn data ->
IO.binwrite(output, data)
end)
:ok
end
@spec decrypt_stream(t(), binary()) :: Enumerable.t()
def decrypt_stream(%__MODULE__{relative_path: filename, encryption_iv: iv}, key) do
start_fun = fn ->
state = :crypto.crypto_init(:aes_256_ctr, key, iv, false)
path = Path.join(upload_dir(), filename)
{:ok, input} = File.open(path, [:read, :binary])
{state, input, false}
end
next_fun = fn {state, input_dev, halt} ->
if halt do
{:halt, {state, input_dev}}
else
# why 4k?
{halt, data} =
case IO.binread(input_dev, 4096) do
:eof ->
{true, :crypto.crypto_final(state)}
data ->
{false, :crypto.crypto_update(state, data)}
end
{[data], {state, input_dev, halt}}
end
end
after_fun = fn {_state, input_dev} ->
File.close(input_dev)
end
Stream.resource(start_fun, next_fun, after_fun)
end
@spec delete_file(upload :: t()) :: :ok
def delete_file(%__MODULE__{relative_path: filename}) do
File.rm(Path.join(upload_dir(), filename))
end
end