This commit is contained in:
Shadowfacts 2020-08-01 16:43:55 -04:00
parent 8715db806a
commit 56d2df5140
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
12 changed files with 265 additions and 24 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -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"

View File

@ -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

View File

@ -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

105
lib/wiki/content/upload.ex Normal file
View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -2,4 +2,35 @@
<%= 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>

View File

@ -1,18 +1,22 @@
<h1>Show Page</h1>
<ul>
<li>
<strong>Title:</strong>
<%= @page.title %>
</li>
<li>
<strong>Content:</strong>
<%= raw(@content) %>
</li>
</ul>
<h1><%= @page.title %></h1>
<span><%= link "Edit", to: Routes.page_path(@conn, :edit, @page) %></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 %>

View File

@ -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

View File

@ -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