avatar uploading

This commit is contained in:
byte[] 2019-12-07 11:26:45 -05:00
parent 4627a82adb
commit ae189bafce
16 changed files with 209 additions and 7 deletions

View file

@ -75,6 +75,7 @@ defmodule Philomena.Images do
end) end)
|> Multi.run(:after, fn _repo, %{image: image} -> |> Multi.run(:after, fn _repo, %{image: image} ->
Uploader.persist_upload(image) Uploader.persist_upload(image)
Uploader.unpersist_old_upload(image)
{:ok, nil} {:ok, nil}
end) end)

View file

@ -93,6 +93,7 @@ defmodule Philomena.Images.Image do
field :added_tags, {:array, :any}, default: [], virtual: true field :added_tags, {:array, :any}, default: [], virtual: true
field :uploaded_image, :string, virtual: true field :uploaded_image, :string, virtual: true
field :removed_image, :string, virtual: true
timestamps(inserted_at: :created_at) timestamps(inserted_at: :created_at)
end end
@ -129,7 +130,7 @@ defmodule Philomena.Images.Image do
:image, :image_name, :image_width, :image_height, :image_size, :image, :image_name, :image_width, :image_height, :image_size,
:image_format, :image_mime_type, :image_aspect_ratio, :image_format, :image_mime_type, :image_aspect_ratio,
:image_orig_sha512_hash, :image_sha512_hash, :uploaded_image, :image_orig_sha512_hash, :image_sha512_hash, :uploaded_image,
:image_is_animated :removed_image, :image_is_animated
]) ])
|> validate_required([ |> validate_required([
:image, :image_width, :image_height, :image_size, :image, :image_width, :image_height, :image_size,

View file

@ -14,6 +14,10 @@ defmodule Philomena.Images.Uploader do
Uploader.persist_upload(image, image_file_root(), "image") Uploader.persist_upload(image, image_file_root(), "image")
end end
def unpersist_old_upload(image) do
Uploader.unpersist_old_upload(image, image_file_root(), "image")
end
defp image_file_root do defp image_file_root do
Application.get_env(:philomena, :image_file_root) Application.get_env(:philomena, :image_file_root)
end end

View file

@ -6,6 +6,7 @@ defmodule Philomena.Uploader do
alias Philomena.Filename alias Philomena.Filename
alias Philomena.Analyzers alias Philomena.Analyzers
alias Philomena.Sha512 alias Philomena.Sha512
import Ecto.Changeset
@doc """ @doc """
Performs analysis of the passed Plug.Upload, and invokes a changeset Performs analysis of the passed Plug.Upload, and invokes a changeset
@ -17,6 +18,11 @@ defmodule Philomena.Uploader do
with {:ok, analysis} <- Analyzers.analyze(upload_parameter), with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
analysis <- extra_attributes(analysis, upload_parameter) analysis <- extra_attributes(analysis, upload_parameter)
do do
removed =
model_or_changeset
|> change()
|> get_field(field(field_name))
attributes = attributes =
%{ %{
"name" => analysis.name, "name" => analysis.name,
@ -33,6 +39,7 @@ defmodule Philomena.Uploader do
|> prefix_attributes(field_name) |> prefix_attributes(field_name)
|> Map.put(field_name, analysis.new_name) |> Map.put(field_name, analysis.new_name)
|> Map.put(upload_key(field_name), upload_parameter.path) |> Map.put(upload_key(field_name), upload_parameter.path)
|> Map.put(remove_key(field_name), removed)
changeset_fn.(model_or_changeset, attributes) changeset_fn.(model_or_changeset, attributes)
else else
@ -42,13 +49,13 @@ defmodule Philomena.Uploader do
end end
@doc """ @doc """
Writes the file to permanent storage. This should be the last step in the Writes the file to permanent storage. This should be the second-to-last step
transaction. in the transaction.
""" """
@spec persist_upload(any(), String.t(), String.t()) :: any() @spec persist_upload(any(), String.t(), String.t()) :: any()
def persist_upload(model, file_root, field_name) do def persist_upload(model, file_root, field_name) do
source = Map.get(model, String.to_existing_atom(upload_key(field_name))) source = Map.get(model, field(upload_key(field_name)))
dest = Map.get(model, String.to_existing_atom(field_name)) dest = Map.get(model, field(field_name))
target = Path.join(file_root, dest) target = Path.join(file_root, dest)
dir = Path.dirname(target) dir = Path.dirname(target)
@ -58,6 +65,17 @@ defmodule Philomena.Uploader do
File.cp!(source, target) File.cp!(source, target)
end end
@doc """
Removes the old file from permanent storage. This should be the last step in
the transaction.
"""
@spec unpersist_old_upload(any(), String.t(), String.t()) :: any()
def unpersist_old_upload(model, file_root, field_name) do
model
|> Map.get(field(remove_key(field_name)))
|> try_remove(file_root)
end
defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do
{width, height} = analysis.dimensions {width, height} = analysis.dimensions
aspect_ratio = aspect_ratio(width, height) aspect_ratio = aspect_ratio(width, height)
@ -79,8 +97,16 @@ defmodule Philomena.Uploader do
defp aspect_ratio(_, 0), do: 0.0 defp aspect_ratio(_, 0), do: 0.0
defp aspect_ratio(w, h), do: w / h defp aspect_ratio(w, h), do: w / h
defp try_remove("", _file_root), do: nil
defp try_remove(nil, _file_root), do: nil
defp try_remove(file, file_root), do: File.rm(Path.join(file_root, file))
defp prefix_attributes(map, prefix), defp prefix_attributes(map, prefix),
do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end) do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end)
defp upload_key(field_name), do: "uploaded_#{field_name}" defp upload_key(field_name), do: "uploaded_#{field_name}"
defp remove_key(field_name), do: "removed_#{field_name}"
defp field(field_name), do: String.to_existing_atom(field_name)
end end

View file

@ -4,8 +4,10 @@ defmodule Philomena.Users do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Ecto.Multi
alias Philomena.Repo alias Philomena.Repo
alias Philomena.Users.Uploader
alias Philomena.Users.User alias Philomena.Users.User
use Pow.Ecto.Context, use Pow.Ecto.Context,
@ -111,6 +113,33 @@ defmodule Philomena.Users do
|> Repo.update() |> Repo.update()
end end
def update_avatar(%User{} = user, attrs) do
changeset = Uploader.analyze_upload(user, attrs)
Multi.new
|> Multi.update(:user, changeset)
|> Multi.run(:update_file, fn _repo, %{user: user} ->
Uploader.persist_upload(user)
Uploader.unpersist_old_upload(user)
{:ok, nil}
end)
|> Repo.isolated_transaction(:serializable)
end
def remove_avatar(%User{} = user) do
changeset = User.remove_avatar_changeset(user)
Multi.new
|> Multi.update(:user, changeset)
|> Multi.run(:remove_file, fn _repo, %{user: user} ->
Uploader.unpersist_old_upload(user)
{:ok, nil}
end)
|> Repo.isolated_transaction(:serializable)
end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking user changes. Returns an `%Ecto.Changeset{}` for tracking user changes.

View file

@ -0,0 +1,24 @@
defmodule Philomena.Users.Uploader do
@moduledoc """
Upload and processing callback logic for User avatars.
"""
alias Philomena.Users.User
alias Philomena.Uploader
def analyze_upload(user, params) do
Uploader.analyze_upload(user, "avatar", params["avatar"], &User.avatar_changeset/2)
end
def persist_upload(user) do
Uploader.persist_upload(user, avatar_file_root(), "avatar")
end
def unpersist_old_upload(user) do
Uploader.unpersist_old_upload(user, avatar_file_root(), "avatar")
end
defp avatar_file_root do
Application.get_env(:philomena, :avatar_file_root)
end
end

View file

@ -120,6 +120,14 @@ defmodule Philomena.Users.User do
field :secondary_role, :string field :secondary_role, :string
field :hide_default_role, :boolean, default: false field :hide_default_role, :boolean, default: false
# For avatar validation/persistence
field :avatar_width, :integer, virtual: true
field :avatar_height, :integer, virtual: true
field :avatar_size, :integer, virtual: true
field :avatar_mime_type, :string, virtual: true
field :uploaded_avatar, :string, virtual: true
field :removed_avatar, :string, virtual: true
timestamps(inserted_at: :created_at) timestamps(inserted_at: :created_at)
end end
@ -193,6 +201,27 @@ defmodule Philomena.Users.User do
|> validate_format(:personal_title, ~r/\A((?!site|admin|moderator|assistant|developer|\p{C}).)*\z/iu) |> validate_format(:personal_title, ~r/\A((?!site|admin|moderator|assistant|developer|\p{C}).)*\z/iu)
end end
def avatar_changeset(user, attrs) do
user
|> cast(attrs, [
:avatar, :avatar_width, :avatar_height, :avatar_size, :uploaded_avatar,
:removed_avatar
])
|> validate_required([
:avatar, :avatar_width, :avatar_height, :avatar_size, :uploaded_avatar
])
|> validate_number(:avatar_size, greater_than: 0, less_than_or_equal_to: 300_000)
|> validate_number(:avatar_width, greater_than: 0, less_than_or_equal_to: 1000)
|> validate_number(:avatar_height, greater_than: 0, less_than_or_equal_to: 1000)
|> validate_inclusion(:avatar_mime_type, ~W(image/gif image/jpeg image/png))
end
def remove_avatar_changeset(user) do
user
|> change(removed_avatar: user.avatar)
|> change(avatar: nil)
end
def watched_tags_changeset(user, watched_tag_ids) do def watched_tags_changeset(user, watched_tag_ids) do
change(user, watched_tag_ids: watched_tag_ids) change(user, watched_tag_ids: watched_tag_ids)
end end

View file

@ -0,0 +1,33 @@
defmodule PhilomenaWeb.AvatarController do
use PhilomenaWeb, :controller
alias Philomena.Users
plug PhilomenaWeb.FilterBannedUsersPlug
plug PhilomenaWeb.ScraperPlug, [params_name: "user", params_key: "avatar"] when action in [:update]
def edit(conn, _params) do
changeset = Users.change_user(conn.assigns.current_user)
render(conn, "edit.html", changeset: changeset)
end
def update(conn, %{"user" => user_params}) do
case Users.update_avatar(conn.assigns.current_user, user_params) do
{:ok, _user} ->
conn
|> put_flash(:info, "Successfully updated avatar.")
|> redirect(to: Routes.avatar_path(conn, :edit))
{:error, :user, changeset, _changes} ->
render(conn, "edit.html", changeset: changeset)
end
end
def delete(conn, _params) do
{:ok, _user} = Users.remove_avatar(conn.assigns.current_user)
conn
|> put_flash(:info, "Successfully removed avatar.")
|> redirect(to: Routes.avatar_path(conn, :edit))
end
end

View file

@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Filter.HideController do
alias Philomena.Tags.Tag alias Philomena.Tags.Tag
alias Philomena.Repo alias Philomena.Repo
plug PhilomenaWeb.FilterBannedUsersPlug
plug :authorize_filter plug :authorize_filter
plug :load_tag plug :load_tag

View file

@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Filter.SpoilerController do
alias Philomena.Tags.Tag alias Philomena.Tags.Tag
alias Philomena.Repo alias Philomena.Repo
plug PhilomenaWeb.FilterBannedUsersPlug
plug :authorize_filter plug :authorize_filter
plug :load_tag plug :load_tag

View file

@ -47,7 +47,7 @@ defmodule PhilomenaWeb.ReportController do
conn conn
|> put_flash(:info, "Your report has been received and will be checked by staff shortly.") |> put_flash(:info, "Your report has been received and will be checked by staff shortly.")
|> redirect(to: "/") |> redirect(to: redirect_path(conn, conn.assigns.current_user))
{:error, changeset} -> {:error, changeset} ->
# Note that we are depending on the controller that called # Note that we are depending on the controller that called
@ -86,4 +86,7 @@ defmodule PhilomenaWeb.ReportController do
reports_open >= 5 reports_open >= 5
end end
defp redirect_path(conn, nil), do: "/"
defp redirect_path(conn, _user), do: Routes.report_path(conn, :index)
end end

View file

@ -134,6 +134,8 @@ defmodule PhilomenaWeb.Router do
resources "/watch", Tag.WatchController, only: [:create, :delete], singleton: true resources "/watch", Tag.WatchController, only: [:create, :delete], singleton: true
end end
resources "/avatar", AvatarController, only: [:edit, :update, :delete], singleton: true
resources "/reports", ReportController, only: [:index] resources "/reports", ReportController, only: [:index]
resources "/user_links", UserLinkController, only: [:index, :new, :create, :show] resources "/user_links", UserLinkController, only: [:index, :new, :create, :show]
resources "/galleries", GalleryController, only: [:new, :create, :edit, :update, :delete] do resources "/galleries", GalleryController, only: [:new, :create, :edit, :update, :delete] do

View file

@ -0,0 +1,42 @@
.profile-top
.profile-top__avatar
= render PhilomenaWeb.UserAttributionView, "_user_avatar.html", object: %{user: @current_user}, conn: @conn
.profile-top__name-and-links
div
h1 Your avatar
p Add a new avatar or remove your existing one here.
p Avatars must be less than 1000px tall and wide, and smaller than 300 kilobytes in size. PNG, JPEG, and GIF are acceptable.
= form_for @changeset, Routes.avatar_path(@conn, :update), [method: "put", multipart: true], fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
/ todo: extract this
h4 Select an image
.image-other
#js-image-upload-previews
p Upload a file from your computer, or provide a link to the page containing the image and click Fetch.
.field
= file_input f, :avatar, class: "input js-scraper"
= error_tag f, :avatar_size
= error_tag f, :avatar_width
= error_tag f, :avatar_height
= error_tag f, :avatar_mime_type
.field.field--inline
= url_input f, :scraper_url, class: "input input--wide js-scraper", placeholder: "Link a deviantART page, a Tumblr post, or the image directly"
button.button.button--separate-left#js-scraper-preview type="button" title="Fetch the image at the specified URL" data-disable-with="Fetch"
' Fetch
.field-error-js.hidden.js-scraper
br
=> submit "Update my avatar", class: "button"
br
= button_to "Remove my avatar", Routes.avatar_path(@conn, :delete), method: "delete", class: "button", data: [confirm: "Are you really, really sure?"]

View file

@ -1,5 +1,4 @@
= form_for @changeset, Routes.image_path(@conn, :create), [multipart: true], fn f -> = form_for @changeset, Routes.image_path(@conn, :create), [multipart: true], fn f ->
.dnp-warning .dnp-warning
h4 h4
' Read the ' Read the

View file

@ -8,6 +8,10 @@ p
' Looking for two-factor authentication? ' Looking for two-factor authentication?
= link "Click here!", to: Routes.registration_totp_path(@conn, :edit) = link "Click here!", to: Routes.registration_totp_path(@conn, :edit)
p
' Looking to change your avatar?
= link "Click here!", to: Routes.avatar_path(@conn, :edit)
h3 API Key h3 API Key
p p
' Your API key is ' Your API key is

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.AvatarView do
use PhilomenaWeb, :view
end