diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 24b559c4..199f856a 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -75,6 +75,7 @@ defmodule Philomena.Images do end) |> Multi.run(:after, fn _repo, %{image: image} -> Uploader.persist_upload(image) + Uploader.unpersist_old_upload(image) {:ok, nil} end) diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index ea6020a9..a5625196 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -93,6 +93,7 @@ defmodule Philomena.Images.Image do field :added_tags, {:array, :any}, default: [], virtual: true field :uploaded_image, :string, virtual: true + field :removed_image, :string, virtual: true timestamps(inserted_at: :created_at) end @@ -129,7 +130,7 @@ defmodule Philomena.Images.Image do :image, :image_name, :image_width, :image_height, :image_size, :image_format, :image_mime_type, :image_aspect_ratio, :image_orig_sha512_hash, :image_sha512_hash, :uploaded_image, - :image_is_animated + :removed_image, :image_is_animated ]) |> validate_required([ :image, :image_width, :image_height, :image_size, diff --git a/lib/philomena/images/uploader.ex b/lib/philomena/images/uploader.ex index 509ac1f1..6688eeb5 100644 --- a/lib/philomena/images/uploader.ex +++ b/lib/philomena/images/uploader.ex @@ -14,6 +14,10 @@ defmodule Philomena.Images.Uploader do Uploader.persist_upload(image, image_file_root(), "image") end + def unpersist_old_upload(image) do + Uploader.unpersist_old_upload(image, image_file_root(), "image") + end + defp image_file_root do Application.get_env(:philomena, :image_file_root) end diff --git a/lib/philomena/uploader.ex b/lib/philomena/uploader.ex index 914a3274..2f8ce01f 100644 --- a/lib/philomena/uploader.ex +++ b/lib/philomena/uploader.ex @@ -6,6 +6,7 @@ defmodule Philomena.Uploader do alias Philomena.Filename alias Philomena.Analyzers alias Philomena.Sha512 + import Ecto.Changeset @doc """ 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), analysis <- extra_attributes(analysis, upload_parameter) do + removed = + model_or_changeset + |> change() + |> get_field(field(field_name)) + attributes = %{ "name" => analysis.name, @@ -33,6 +39,7 @@ defmodule Philomena.Uploader do |> prefix_attributes(field_name) |> Map.put(field_name, analysis.new_name) |> Map.put(upload_key(field_name), upload_parameter.path) + |> Map.put(remove_key(field_name), removed) changeset_fn.(model_or_changeset, attributes) else @@ -42,13 +49,13 @@ defmodule Philomena.Uploader do end @doc """ - Writes the file to permanent storage. This should be the last step in the - transaction. + Writes the file to permanent storage. This should be the second-to-last step + in the transaction. """ @spec persist_upload(any(), String.t(), String.t()) :: any() def persist_upload(model, file_root, field_name) do - source = Map.get(model, String.to_existing_atom(upload_key(field_name))) - dest = Map.get(model, String.to_existing_atom(field_name)) + source = Map.get(model, field(upload_key(field_name))) + dest = Map.get(model, field(field_name)) target = Path.join(file_root, dest) dir = Path.dirname(target) @@ -58,6 +65,17 @@ defmodule Philomena.Uploader do File.cp!(source, target) 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 {width, height} = analysis.dimensions aspect_ratio = aspect_ratio(width, height) @@ -79,8 +97,16 @@ defmodule Philomena.Uploader do defp aspect_ratio(_, 0), do: 0.0 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), do: Map.new(map, fn {key, value} -> {"#{prefix}_#{key}", value} end) 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 \ No newline at end of file diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index 4498ae6c..9016d2ab 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -4,8 +4,10 @@ defmodule Philomena.Users do """ import Ecto.Query, warn: false + alias Ecto.Multi alias Philomena.Repo + alias Philomena.Users.Uploader alias Philomena.Users.User use Pow.Ecto.Context, @@ -111,6 +113,33 @@ defmodule Philomena.Users do |> Repo.update() 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 """ Returns an `%Ecto.Changeset{}` for tracking user changes. diff --git a/lib/philomena/users/uploader.ex b/lib/philomena/users/uploader.ex new file mode 100644 index 00000000..14cb03b4 --- /dev/null +++ b/lib/philomena/users/uploader.ex @@ -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 \ No newline at end of file diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 7be31121..3861963b 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -120,6 +120,14 @@ defmodule Philomena.Users.User do field :secondary_role, :string 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) 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) 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 change(user, watched_tag_ids: watched_tag_ids) end diff --git a/lib/philomena_web/controllers/avatar_controller.ex b/lib/philomena_web/controllers/avatar_controller.ex new file mode 100644 index 00000000..9ae4b5c4 --- /dev/null +++ b/lib/philomena_web/controllers/avatar_controller.ex @@ -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 \ No newline at end of file diff --git a/lib/philomena_web/controllers/filter/hide_controller.ex b/lib/philomena_web/controllers/filter/hide_controller.ex index b76637ce..5aee4553 100644 --- a/lib/philomena_web/controllers/filter/hide_controller.ex +++ b/lib/philomena_web/controllers/filter/hide_controller.ex @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Filter.HideController do alias Philomena.Tags.Tag alias Philomena.Repo + plug PhilomenaWeb.FilterBannedUsersPlug plug :authorize_filter plug :load_tag diff --git a/lib/philomena_web/controllers/filter/spoiler_controller.ex b/lib/philomena_web/controllers/filter/spoiler_controller.ex index e9ba0fe7..2fbad82b 100644 --- a/lib/philomena_web/controllers/filter/spoiler_controller.ex +++ b/lib/philomena_web/controllers/filter/spoiler_controller.ex @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Filter.SpoilerController do alias Philomena.Tags.Tag alias Philomena.Repo + plug PhilomenaWeb.FilterBannedUsersPlug plug :authorize_filter plug :load_tag diff --git a/lib/philomena_web/controllers/report_controller.ex b/lib/philomena_web/controllers/report_controller.ex index 14726588..79962009 100644 --- a/lib/philomena_web/controllers/report_controller.ex +++ b/lib/philomena_web/controllers/report_controller.ex @@ -47,7 +47,7 @@ defmodule PhilomenaWeb.ReportController do conn |> 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} -> # Note that we are depending on the controller that called @@ -86,4 +86,7 @@ defmodule PhilomenaWeb.ReportController do reports_open >= 5 end + + defp redirect_path(conn, nil), do: "/" + defp redirect_path(conn, _user), do: Routes.report_path(conn, :index) end \ No newline at end of file diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 98bc8e16..cbc41d71 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -134,6 +134,8 @@ defmodule PhilomenaWeb.Router do resources "/watch", Tag.WatchController, only: [:create, :delete], singleton: true end + resources "/avatar", AvatarController, only: [:edit, :update, :delete], singleton: true + resources "/reports", ReportController, only: [:index] resources "/user_links", UserLinkController, only: [:index, :new, :create, :show] resources "/galleries", GalleryController, only: [:new, :create, :edit, :update, :delete] do diff --git a/lib/philomena_web/templates/avatar/edit.html.slime b/lib/philomena_web/templates/avatar/edit.html.slime new file mode 100644 index 00000000..f88d4c32 --- /dev/null +++ b/lib/philomena_web/templates/avatar/edit.html.slime @@ -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?"] \ No newline at end of file diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime index 7da935ee..35f7324e 100644 --- a/lib/philomena_web/templates/image/new.html.slime +++ b/lib/philomena_web/templates/image/new.html.slime @@ -1,5 +1,4 @@ = form_for @changeset, Routes.image_path(@conn, :create), [multipart: true], fn f -> - .dnp-warning h4 ' Read the diff --git a/lib/philomena_web/templates/pow/registration/edit.html.slime b/lib/philomena_web/templates/pow/registration/edit.html.slime index f0611102..f6a475aa 100644 --- a/lib/philomena_web/templates/pow/registration/edit.html.slime +++ b/lib/philomena_web/templates/pow/registration/edit.html.slime @@ -8,6 +8,10 @@ p ' Looking for two-factor authentication? = 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 p ' Your API key is diff --git a/lib/philomena_web/views/avatar_view.ex b/lib/philomena_web/views/avatar_view.ex new file mode 100644 index 00000000..82e63a40 --- /dev/null +++ b/lib/philomena_web/views/avatar_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.AvatarView do + use PhilomenaWeb, :view +end