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