From 56d2df5140193882ca21eeda780ac1878b3571bd Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 1 Aug 2020 16:43:55 -0400 Subject: [PATCH] Uploads --- .gitignore | 4 + config/dev.exs | 2 + lib/wiki/content.ex | 9 ++ lib/wiki/content/page.ex | 4 +- lib/wiki/content/upload.ex | 105 ++++++++++++++++++ lib/wiki_web/controllers/page_controller.ex | 71 +++++++++++- lib/wiki_web/endpoint.ex | 6 +- lib/wiki_web/router.ex | 3 + lib/wiki_web/templates/page/edit.html.eex | 31 ++++++ lib/wiki_web/templates/page/show.html.eex | 36 +++--- .../20200726020001_create_pages.exs | 2 +- .../20200730022812_create_uploads.exs | 16 +++ 12 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 lib/wiki/content/upload.ex create mode 100644 priv/repo/migrations/20200730022812_create_uploads.exs diff --git a/.gitignore b/.gitignore index 52a0b51..4c201a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # The directory Mix will write compiled artifacts to. /_build/ @@ -32,3 +34,5 @@ npm-debug.log # we ignore priv/static. You may want to comment # this depending on your deployment strategy. /priv/static/ + +uploads/ diff --git a/config/dev.exs b/config/dev.exs index 4d05248..a4b6d53 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -74,3 +74,5 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime + +config :wiki, :upload_dir, "uploads" diff --git a/lib/wiki/content.ex b/lib/wiki/content.ex index 5ece37d..4c6ea94 100644 --- a/lib/wiki/content.ex +++ b/lib/wiki/content.ex @@ -7,6 +7,7 @@ defmodule Wiki.Content do alias Wiki.Repo alias Wiki.Content.Page + alias Wiki.Content.Upload @doc """ Returns the list of pages. @@ -103,4 +104,12 @@ defmodule Wiki.Content do def change_page(%Page{} = page, attrs \\ %{}, options \\ []) do Page.changeset(page, attrs, options) end + + def get_upload(%Page{id: page_id}, id) do + get_upload(page_id, id) + end + + def get_upload(page_id, upload_id) do + Repo.one(from u in Upload, where: u.id == ^upload_id and u.page_id == ^page_id) + end end diff --git a/lib/wiki/content/page.ex b/lib/wiki/content/page.ex index 800fa75..6faca84 100644 --- a/lib/wiki/content/page.ex +++ b/lib/wiki/content/page.ex @@ -10,7 +10,8 @@ defmodule Wiki.Content.Page do content_encryption_key: String.t() | nil, html: String.t() | nil, title: String.t(), - user: Wiki.Accounts.User.t() + user: Wiki.Accounts.User.t(), + uploads: [Wiki.Content.Upload.t()] } schema "pages" do @@ -23,6 +24,7 @@ defmodule Wiki.Content.Page do field :title, :string belongs_to :user, Wiki.Accounts.User + has_many :uploads, Wiki.Content.Upload timestamps() end diff --git a/lib/wiki/content/upload.ex b/lib/wiki/content/upload.ex new file mode 100644 index 0000000..33f9244 --- /dev/null +++ b/lib/wiki/content/upload.ex @@ -0,0 +1,105 @@ +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() + defp 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 delete_file(upload :: t()) :: :ok + def delete_file(%__MODULE__{relative_path: filename}) do + File.rm(Path.join(upload_dir(), filename)) + end +end diff --git a/lib/wiki_web/controllers/page_controller.ex b/lib/wiki_web/controllers/page_controller.ex index bb99c25..faf7289 100644 --- a/lib/wiki_web/controllers/page_controller.ex +++ b/lib/wiki_web/controllers/page_controller.ex @@ -1,10 +1,22 @@ defmodule WikiWeb.PageController do use WikiWeb, :controller + alias Wiki.Repo alias Wiki.Content - alias Wiki.Content.Page + alias Wiki.Content.{Page, Upload} - plug :get_page when action in [:show, :edit, :update, :delete] + plug :get_page + when action in [ + :show, + :edit, + :update, + :delete, + :create_upload, + :get_upload, + :delete_upload + ] + + plug :get_upload_plug when action in [:get_upload, :delete_upload] defp get_page(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do case Content.get_page(id) do @@ -29,6 +41,20 @@ defmodule WikiWeb.PageController do end end + defp get_upload_plug(%Plug.Conn{path_params: %{"upload_id" => id}} = conn, _opts) do + page = conn.assigns.page + + case Content.get_upload(page, id) do + nil -> + conn + |> send_resp(404, "Not found") + |> halt() + + upload -> + assign(conn, :upload, upload) + end + end + def index(conn, _params) do pages = Content.list_pages() render(conn, "index.html", pages: pages) @@ -59,12 +85,13 @@ defmodule WikiWeb.PageController do end def show(conn, _params) do - rendered_content = Content.Renderer.render(conn.assigns.page) - render(conn, "show.html", page: conn.assigns.page, content: rendered_content) + page = Repo.preload(conn.assigns.page, :uploads) + rendered_content = Content.Renderer.render(page) + render(conn, "show.html", page: page, content: rendered_content) end def edit(conn, _params) do - page = conn.assigns.page + page = conn.assigns.page |> Repo.preload(:uploads) changeset = Content.change_page(page) render(conn, "edit.html", page: page, changeset: changeset) end @@ -83,6 +110,40 @@ defmodule WikiWeb.PageController do end end + def create_upload(conn, %{"file" => %Plug.Upload{} = file}) do + page = conn.assigns.page + key = get_session(conn, :content_encryption_key) + key = Base.decode16!(key, case: :lower) + + changeset = + Upload.create_from_plug(file, key) + |> Upload.changeset(%{page_id: page.id}) + + {:ok, _upload} = Repo.insert(changeset) + + redirect(conn, to: Routes.page_path(conn, :edit, page.id)) + end + + def get_upload(conn, _params) do + upload = conn.assigns.upload + key = get_session(conn, :content_encryption_key) + key = Base.decode16!(key, case: :lower) + + data = Upload.decrypt_content(upload, key) + + conn + |> put_resp_header("content-type", upload.content_type) + |> send_resp(200, data) + end + + def delete_upload(conn, _params) do + page = conn.assigns.page + upload = conn.assigns.upload + Upload.delete_file(upload) + Repo.delete(upload) + redirect(conn, to: Routes.page_path(conn, :edit, page.id)) + end + def delete(conn, _params) do page = conn.assigns.page {:ok, _page} = Content.delete_page(page) diff --git a/lib/wiki_web/endpoint.ex b/lib/wiki_web/endpoint.ex index 0b79ddd..4c84c5a 100644 --- a/lib/wiki_web/endpoint.ex +++ b/lib/wiki_web/endpoint.ex @@ -43,7 +43,11 @@ defmodule WikiWeb.Endpoint do plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], + parsers: [ + :urlencoded, + {:multipart, length: 5_000_000_000}, + :json + ], pass: ["*/*"], json_decoder: Phoenix.json_library() diff --git a/lib/wiki_web/router.ex b/lib/wiki_web/router.ex index 3a03a21..13c2f91 100644 --- a/lib/wiki_web/router.ex +++ b/lib/wiki_web/router.ex @@ -67,6 +67,9 @@ defmodule WikiWeb.Router do get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email resources "/pages", PageController + post "/pages/:id/uploads", PageController, :create_upload + get "/pages/:id/uploads/:upload_id", PageController, :get_upload + delete "/pages/:id/uploads/:upload_id", PageController, :delete_upload end scope "/", WikiWeb do diff --git a/lib/wiki_web/templates/page/edit.html.eex b/lib/wiki_web/templates/page/edit.html.eex index 69b9819..5df4f01 100644 --- a/lib/wiki_web/templates/page/edit.html.eex +++ b/lib/wiki_web/templates/page/edit.html.eex @@ -2,4 +2,35 @@ <%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :update, @page)) %> +

Uploaded Files

+ + + + + + + <%= for upload <- @page.uploads do %> + + + + + + <% end %> +
Encrypted FilenameContent Type
+ + <%= upload.relative_path %> + + + <%= upload.content_type %> + + <%= form_tag Routes.page_path(@conn, :delete_upload, @page.id, upload.id), method: :delete, style: "margin: 0;" do %> + <%= submit "Delete", style: "margin: 0;" %> + <% end %> +
+ +<%= form_tag Routes.page_path(@conn, :create_upload, @page.id), method: :post, multipart: true do %> + + <%= submit "Upload" %> +<% end %> + <%= link "Back", to: Routes.page_path(@conn, :index) %> diff --git a/lib/wiki_web/templates/page/show.html.eex b/lib/wiki_web/templates/page/show.html.eex index c959674..4c8e558 100644 --- a/lib/wiki_web/templates/page/show.html.eex +++ b/lib/wiki_web/templates/page/show.html.eex @@ -1,18 +1,22 @@ -

Show Page

- - - +

<%= @page.title %>

<%= link "Edit", to: Routes.page_path(@conn, :edit, @page) %> <%= link "Back", to: Routes.page_path(@conn, :index) %> + +
+ <%= raw(@content) %> +
+ +<%= for upload <- @page.uploads do %> +
+ <%= cond do %> + <% String.starts_with?(upload.content_type, "image/") -> %> + + <% String.starts_with?(upload.content_type, "video/") -> %> + + <% String.starts_with?(upload.content_type, "audio/") -> %> + + <% true -> %> + <%= upload.relative_path %> + <% end %> +
+<% end %> diff --git a/priv/repo/migrations/20200726020001_create_pages.exs b/priv/repo/migrations/20200726020001_create_pages.exs index ea1e1f9..14ce625 100644 --- a/priv/repo/migrations/20200726020001_create_pages.exs +++ b/priv/repo/migrations/20200726020001_create_pages.exs @@ -7,7 +7,7 @@ defmodule Wiki.Repo.Migrations.CreatePages do add :encrypted_content, :binary add :encrypted_content_iv, :binary add :encrypted_content_tag, :binary - add :user_id, references(:users, on_delete: :nothing) + add :user_id, references(:users, on_delete: :delete_all) timestamps() end diff --git a/priv/repo/migrations/20200730022812_create_uploads.exs b/priv/repo/migrations/20200730022812_create_uploads.exs new file mode 100644 index 0000000..8f6a9d7 --- /dev/null +++ b/priv/repo/migrations/20200730022812_create_uploads.exs @@ -0,0 +1,16 @@ +defmodule Wiki.Repo.Migrations.CreateUploads do + use Ecto.Migration + + def change do + create table(:uploads) do + add :relative_path, :string + add :content_type, :string + add :encryption_iv, :binary + add :page_id, references(:pages, on_delete: :delete_all) + + timestamps() + end + + create index(:uploads, [:page_id]) + end +end