Uploads
This commit is contained in:
parent
8715db806a
commit
56d2df5140
|
@ -1,3 +1,5 @@
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# The directory Mix will write compiled artifacts to.
|
# The directory Mix will write compiled artifacts to.
|
||||||
/_build/
|
/_build/
|
||||||
|
|
||||||
|
@ -32,3 +34,5 @@ npm-debug.log
|
||||||
# we ignore priv/static. You may want to comment
|
# we ignore priv/static. You may want to comment
|
||||||
# this depending on your deployment strategy.
|
# this depending on your deployment strategy.
|
||||||
/priv/static/
|
/priv/static/
|
||||||
|
|
||||||
|
uploads/
|
||||||
|
|
|
@ -74,3 +74,5 @@ config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
||||||
# Initialize plugs at runtime for faster development compilation
|
# Initialize plugs at runtime for faster development compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
config :wiki, :upload_dir, "uploads"
|
||||||
|
|
|
@ -7,6 +7,7 @@ defmodule Wiki.Content do
|
||||||
alias Wiki.Repo
|
alias Wiki.Repo
|
||||||
|
|
||||||
alias Wiki.Content.Page
|
alias Wiki.Content.Page
|
||||||
|
alias Wiki.Content.Upload
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of pages.
|
Returns the list of pages.
|
||||||
|
@ -103,4 +104,12 @@ defmodule Wiki.Content do
|
||||||
def change_page(%Page{} = page, attrs \\ %{}, options \\ []) do
|
def change_page(%Page{} = page, attrs \\ %{}, options \\ []) do
|
||||||
Page.changeset(page, attrs, options)
|
Page.changeset(page, attrs, options)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -10,7 +10,8 @@ defmodule Wiki.Content.Page do
|
||||||
content_encryption_key: String.t() | nil,
|
content_encryption_key: String.t() | nil,
|
||||||
html: String.t() | nil,
|
html: String.t() | nil,
|
||||||
title: String.t(),
|
title: String.t(),
|
||||||
user: Wiki.Accounts.User.t()
|
user: Wiki.Accounts.User.t(),
|
||||||
|
uploads: [Wiki.Content.Upload.t()]
|
||||||
}
|
}
|
||||||
|
|
||||||
schema "pages" do
|
schema "pages" do
|
||||||
|
@ -23,6 +24,7 @@ defmodule Wiki.Content.Page do
|
||||||
field :title, :string
|
field :title, :string
|
||||||
|
|
||||||
belongs_to :user, Wiki.Accounts.User
|
belongs_to :user, Wiki.Accounts.User
|
||||||
|
has_many :uploads, Wiki.Content.Upload
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -1,10 +1,22 @@
|
||||||
defmodule WikiWeb.PageController do
|
defmodule WikiWeb.PageController do
|
||||||
use WikiWeb, :controller
|
use WikiWeb, :controller
|
||||||
|
|
||||||
|
alias Wiki.Repo
|
||||||
alias Wiki.Content
|
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
|
defp get_page(%Plug.Conn{path_params: %{"id" => id}} = conn, _opts) do
|
||||||
case Content.get_page(id) do
|
case Content.get_page(id) do
|
||||||
|
@ -29,6 +41,20 @@ defmodule WikiWeb.PageController do
|
||||||
end
|
end
|
||||||
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
|
def index(conn, _params) do
|
||||||
pages = Content.list_pages()
|
pages = Content.list_pages()
|
||||||
render(conn, "index.html", pages: pages)
|
render(conn, "index.html", pages: pages)
|
||||||
|
@ -59,12 +85,13 @@ defmodule WikiWeb.PageController do
|
||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, _params) do
|
def show(conn, _params) do
|
||||||
rendered_content = Content.Renderer.render(conn.assigns.page)
|
page = Repo.preload(conn.assigns.page, :uploads)
|
||||||
render(conn, "show.html", page: conn.assigns.page, content: rendered_content)
|
rendered_content = Content.Renderer.render(page)
|
||||||
|
render(conn, "show.html", page: page, content: rendered_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit(conn, _params) do
|
def edit(conn, _params) do
|
||||||
page = conn.assigns.page
|
page = conn.assigns.page |> Repo.preload(:uploads)
|
||||||
changeset = Content.change_page(page)
|
changeset = Content.change_page(page)
|
||||||
render(conn, "edit.html", page: page, changeset: changeset)
|
render(conn, "edit.html", page: page, changeset: changeset)
|
||||||
end
|
end
|
||||||
|
@ -83,6 +110,40 @@ defmodule WikiWeb.PageController do
|
||||||
end
|
end
|
||||||
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
|
def delete(conn, _params) do
|
||||||
page = conn.assigns.page
|
page = conn.assigns.page
|
||||||
{:ok, _page} = Content.delete_page(page)
|
{:ok, _page} = Content.delete_page(page)
|
||||||
|
|
|
@ -43,7 +43,11 @@ defmodule WikiWeb.Endpoint do
|
||||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
plug Plug.Parsers,
|
plug Plug.Parsers,
|
||||||
parsers: [:urlencoded, :multipart, :json],
|
parsers: [
|
||||||
|
:urlencoded,
|
||||||
|
{:multipart, length: 5_000_000_000},
|
||||||
|
:json
|
||||||
|
],
|
||||||
pass: ["*/*"],
|
pass: ["*/*"],
|
||||||
json_decoder: Phoenix.json_library()
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,9 @@ defmodule WikiWeb.Router do
|
||||||
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
|
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
|
||||||
|
|
||||||
resources "/pages", PageController
|
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
|
end
|
||||||
|
|
||||||
scope "/", WikiWeb do
|
scope "/", WikiWeb do
|
||||||
|
|
|
@ -2,4 +2,35 @@
|
||||||
|
|
||||||
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :update, @page)) %>
|
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :update, @page)) %>
|
||||||
|
|
||||||
|
<h2>Uploaded Files</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Encrypted Filename</th>
|
||||||
|
<th>Content Type</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<%= for upload <- @page.uploads do %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="<%= Routes.page_path(@conn, :get_upload, @page.id, upload.id) %>">
|
||||||
|
<%= upload.relative_path %>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code><%= upload.content_type %></code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= form_tag Routes.page_path(@conn, :delete_upload, @page.id, upload.id), method: :delete, style: "margin: 0;" do %>
|
||||||
|
<%= submit "Delete", style: "margin: 0;" %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<%= form_tag Routes.page_path(@conn, :create_upload, @page.id), method: :post, multipart: true do %>
|
||||||
|
<input type="file" name="file">
|
||||||
|
<%= submit "Upload" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span>
|
<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span>
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
<h1>Show Page</h1>
|
<h1><%= @page.title %></h1>
|
||||||
|
|
||||||
<ul>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<strong>Title:</strong>
|
|
||||||
<%= @page.title %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<strong>Content:</strong>
|
|
||||||
<%= raw(@content) %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<span><%= link "Edit", to: Routes.page_path(@conn, :edit, @page) %></span>
|
<span><%= link "Edit", to: Routes.page_path(@conn, :edit, @page) %></span>
|
||||||
<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span>
|
<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= raw(@content) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= for upload <- @page.uploads do %>
|
||||||
|
<div>
|
||||||
|
<%= cond do %>
|
||||||
|
<% String.starts_with?(upload.content_type, "image/") -> %>
|
||||||
|
<img src="<%= Routes.page_path(@conn, :get_upload, @page.id, upload.id) %>" />
|
||||||
|
<% String.starts_with?(upload.content_type, "video/") -> %>
|
||||||
|
<video controls src="<%= Routes.page_path(@conn, :get_upload, @page.id, upload.id) %>"></video>
|
||||||
|
<% String.starts_with?(upload.content_type, "audio/") -> %>
|
||||||
|
<audio controls src="<%= Routes.page_path(@conn, :get_upload, @page.id, upload.id) %>"></audio>
|
||||||
|
<% true -> %>
|
||||||
|
<a href="<%= Routes.page_path(@conn, :get_upload, @page.id, upload.id) %>"><%= upload.relative_path %></a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
|
@ -7,7 +7,7 @@ defmodule Wiki.Repo.Migrations.CreatePages do
|
||||||
add :encrypted_content, :binary
|
add :encrypted_content, :binary
|
||||||
add :encrypted_content_iv, :binary
|
add :encrypted_content_iv, :binary
|
||||||
add :encrypted_content_tag, :binary
|
add :encrypted_content_tag, :binary
|
||||||
add :user_id, references(:users, on_delete: :nothing)
|
add :user_id, references(:users, on_delete: :delete_all)
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue