diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 86ecd78d..3deb5948 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -10,6 +10,7 @@ defmodule Philomena.Comments do alias Philomena.Elasticsearch alias Philomena.Reports.Report alias Philomena.Comments.Comment + alias Philomena.Comments.ElasticsearchIndex, as: CommentIndex alias Philomena.Images.Image alias Philomena.Images alias Philomena.Notifications @@ -188,6 +189,12 @@ defmodule Philomena.Comments do Comment.changeset(comment, %{}) end + def user_name_reindex(old_name, new_name) do + data = CommentIndex.user_name_update_by_query(old_name, new_name) + + Elasticsearch.update_by_query(Comment, data.query, data.set_replacements, data.replacements) + end + def reindex_comment(%Comment{} = comment) do spawn(fn -> Comment diff --git a/lib/philomena/comments/elasticsearch_index.ex b/lib/philomena/comments/elasticsearch_index.ex index b5779124..8e3ef4a7 100644 --- a/lib/philomena/comments/elasticsearch_index.ex +++ b/lib/philomena/comments/elasticsearch_index.ex @@ -60,4 +60,12 @@ defmodule Philomena.Comments.ElasticsearchIndex do body: comment.body } end + + def user_name_update_by_query(old_name, new_name) do + %{ + query: %{term: %{author: old_name}}, + replacements: [%{path: ["author"], old: old_name, new: new_name}], + set_replacements: [] + } + end end diff --git a/lib/philomena/elasticsearch.ex b/lib/philomena/elasticsearch.ex index 036b72f7..c9fd2305 100644 --- a/lib/philomena/elasticsearch.ex +++ b/lib/philomena/elasticsearch.ex @@ -3,6 +3,7 @@ defmodule Philomena.Elasticsearch do alias Philomena.Repo require Logger import Ecto.Query + import Elastix.HTTP alias Philomena.Comments.Comment alias Philomena.Galleries.Gallery @@ -106,6 +107,71 @@ defmodule Philomena.Elasticsearch do end) end + def update_by_query(module, query_body, set_replacements, replacements) do + index = index_for(module) + + url = + elastic_url() + |> prepare_url([index.index_name(), "_update_by_query"]) + |> append_query_string(%{conflicts: "proceed"}) + + # Elasticsearch "Painless" scripting language + script = """ + // Replace values in "sets" (arrays in the source document) + for (int i = 0; i < params.set_replacements.length; ++i) { + def replacement = params.set_replacements[i]; + def path = replacement.path; + def old_value = replacement.old; + def new_value = replacement.new; + def reference = ctx._source; + + for (int j = 0; j < path.length; ++j) { + reference = reference[path[j]]; + } + + for (int j = 0; j < reference.length; ++j) { + if (reference[j].equals(old_value)) { + reference[j] = new_value; + } + } + } + + // Replace values in standalone fields + for (int i = 0; i < params.replacements.length; ++i) { + def replacement = params.replacements[i]; + def path = replacement.path; + def old_value = replacement.old; + def new_value = replacement.new; + def reference = ctx._source; + + // A little bit more complicated: go up to the last one before it + // so that the value can actually be replaced + + for (int j = 0; j < path.length - 1; ++j) { + reference = reference[path[j]]; + } + + if (reference[path[path.length - 1]] != null && reference[path[path.length - 1]].equals(old_value)) { + reference[path[path.length - 1]] = new_value; + } + } + """ + + body = + Jason.encode!(%{ + script: %{ + source: script, + params: %{ + set_replacements: set_replacements, + replacements: replacements + } + }, + query: query_body + }) + + {:ok, %{status_code: 200}} = Elastix.HTTP.post(url, body) + end + def search(module, query_body) do index = index_for(module) diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index bb679326..c05a1190 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -10,6 +10,7 @@ defmodule Philomena.Galleries do alias Philomena.Elasticsearch alias Philomena.Galleries.Gallery alias Philomena.Galleries.Interaction + alias Philomena.Galleries.ElasticsearchIndex, as: GalleryIndex alias Philomena.Notifications alias Philomena.Images @@ -122,6 +123,12 @@ defmodule Philomena.Galleries do Gallery.changeset(gallery, %{}) end + def user_name_reindex(old_name, new_name) do + data = GalleryIndex.user_name_update_by_query(old_name, new_name) + + Elasticsearch.update_by_query(Gallery, data.query, data.set_replacements, data.replacements) + end + def reindex_gallery(%Gallery{} = gallery) do spawn(fn -> Gallery diff --git a/lib/philomena/galleries/elasticsearch_index.ex b/lib/philomena/galleries/elasticsearch_index.ex index a948609f..3e047ba0 100644 --- a/lib/philomena/galleries/elasticsearch_index.ex +++ b/lib/philomena/galleries/elasticsearch_index.ex @@ -59,4 +59,15 @@ defmodule Philomena.Galleries.ElasticsearchIndex do description: gallery.description } end + + def user_name_update_by_query(old_name, new_name) do + old_name = String.downcase(old_name) + new_name = String.downcase(new_name) + + %{ + query: %{term: %{creator: old_name}}, + replacements: [%{path: ["creator"], old: old_name, new: new_name}], + set_replacements: [] + } + end end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 01d929a5..e7e69fe3 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -14,6 +14,7 @@ defmodule Philomena.Images do alias Philomena.Images.Hider alias Philomena.Images.Uploader alias Philomena.Images.Tagging + alias Philomena.Images.ElasticsearchIndex, as: ImageIndex alias Philomena.ImageFeatures.ImageFeature alias Philomena.SourceChanges.SourceChange alias Philomena.TagChanges.TagChange @@ -522,6 +523,12 @@ defmodule Philomena.Images do Image.changeset(image, %{}) end + def user_name_reindex(old_name, new_name) do + data = ImageIndex.user_name_update_by_query(old_name, new_name) + + Elasticsearch.update_by_query(Image, data.query, data.set_replacements, data.replacements) + end + def reindex_image(%Image{} = image) do reindex_images([image.id]) diff --git a/lib/philomena/images/elasticsearch_index.ex b/lib/philomena/images/elasticsearch_index.ex index 831546f9..d3a0c789 100644 --- a/lib/philomena/images/elasticsearch_index.ex +++ b/lib/philomena/images/elasticsearch_index.ex @@ -143,6 +143,38 @@ defmodule Philomena.Images.ElasticsearchIndex do } end + def user_name_update_by_query(old_name, new_name) do + old_name = String.downcase(old_name) + new_name = String.downcase(new_name) + + %{ + query: %{ + bool: %{ + should: [ + %{term: %{uploader: old_name}}, + %{term: %{true_uploader: old_name}}, + %{term: %{deleted_by_user: old_name}}, + %{term: %{favourited_by_users: old_name}}, + %{term: %{hidden_by_users: old_name}}, + %{term: %{upvoters: old_name}}, + %{term: %{downvoters: old_name}} + ] + } + }, + replacements: [ + %{path: ["uploader"], old: old_name, new: new_name}, + %{path: ["true_uploader"], old: old_name, new: new_name}, + %{path: ["deleted_by_user"], old: old_name, new: new_name} + ], + set_replacements: [ + %{path: ["favourited_by_users"], old: old_name, new: new_name}, + %{path: ["hidden_by_users"], old: old_name, new: new_name}, + %{path: ["upvoters"], old: old_name, new: new_name}, + %{path: ["downvoters"], old: old_name, new: new_name} + ] + } + end + def wilson_score(%{upvotes_count: upvotes, downvotes_count: downvotes}) when upvotes > 0 do # Population size n = (upvotes + downvotes) / 1 diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 7d7a2ddc..971f1754 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -11,6 +11,7 @@ defmodule Philomena.Posts do alias Philomena.Topics.Topic alias Philomena.Topics alias Philomena.Posts.Post + alias Philomena.Posts.ElasticsearchIndex, as: PostIndex alias Philomena.Forums.Forum alias Philomena.Notifications alias Philomena.Versions @@ -204,6 +205,12 @@ defmodule Philomena.Posts do Post.changeset(post, %{}) end + def user_name_reindex(old_name, new_name) do + data = PostIndex.user_name_update_by_query(old_name, new_name) + + Elasticsearch.update_by_query(Post, data.query, data.set_replacements, data.replacements) + end + def reindex_post(%Post{} = post) do spawn(fn -> Post diff --git a/lib/philomena/posts/elasticsearch_index.ex b/lib/philomena/posts/elasticsearch_index.ex index 4644678d..fb2a1bc2 100644 --- a/lib/philomena/posts/elasticsearch_index.ex +++ b/lib/philomena/posts/elasticsearch_index.ex @@ -72,4 +72,15 @@ defmodule Philomena.Posts.ElasticsearchIndex do destroyed_content: post.destroyed_content } end + + def user_name_update_by_query(old_name, new_name) do + old_name = String.downcase(old_name) + new_name = String.downcase(new_name) + + %{ + query: %{term: %{author: old_name}}, + replacements: [%{path: ["author"], old: old_name, new: new_name}], + set_replacements: [] + } + end end diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 7331c7e5..9fd504aa 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -8,6 +8,7 @@ defmodule Philomena.Reports do alias Philomena.Elasticsearch alias Philomena.Reports.Report + alias Philomena.Reports.ElasticsearchIndex, as: ReportIndex alias Philomena.Polymorphic @doc """ @@ -122,6 +123,12 @@ defmodule Philomena.Reports do |> Repo.update() end + def user_name_reindex(old_name, new_name) do + data = ReportIndex.user_name_update_by_query(old_name, new_name) + + Elasticsearch.update_by_query(Report, data.query, data.set_replacements, data.replacements) + end + def reindex_reports(report_ids) do spawn(fn -> Report diff --git a/lib/philomena/reports/elasticsearch_index.ex b/lib/philomena/reports/elasticsearch_index.ex index 6a953fdd..f4f5ff1e 100644 --- a/lib/philomena/reports/elasticsearch_index.ex +++ b/lib/philomena/reports/elasticsearch_index.ex @@ -65,6 +65,27 @@ defmodule Philomena.Reports.ElasticsearchIndex do } end + def user_name_update_by_query(old_name, new_name) do + old_name = String.downcase(old_name) + new_name = String.downcase(new_name) + + %{ + query: %{ + bool: %{ + should: [ + %{term: %{user: old_name}}, + %{term: %{admin: old_name}} + ] + } + }, + replacements: [ + %{path: ["user"], old: old_name, new: new_name}, + %{path: ["admin"], old: old_name, new: new_name} + ], + set_replacements: [] + } + end + defp image_id(%{reportable_type: "Image", reportable_id: image_id}), do: image_id defp image_id(%{reportable_type: "Comment", reportable: %{image_id: image_id}}), do: image_id defp image_id(_report), do: nil diff --git a/lib/philomena/user_name_changes/user_name_change.ex b/lib/philomena/user_name_changes/user_name_change.ex index 06b8c765..824533e6 100644 --- a/lib/philomena/user_name_changes/user_name_change.ex +++ b/lib/philomena/user_name_changes/user_name_change.ex @@ -12,9 +12,9 @@ defmodule Philomena.UserNameChanges.UserNameChange do end @doc false - def changeset(user_name_change, attrs) do + def changeset(user_name_change, old_name) do user_name_change - |> cast(attrs, []) + |> change(name: old_name) |> validate_required([]) end end diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index d02e3657..ab3fc434 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -12,6 +12,12 @@ defmodule Philomena.Users do alias Philomena.{Forums, Forums.Forum} alias Philomena.Topics alias Philomena.Roles.Role + alias Philomena.UserNameChanges.UserNameChange + alias Philomena.Images + alias Philomena.Comments + alias Philomena.Posts + alias Philomena.Galleries + alias Philomena.Reports use Pow.Ecto.Context, repo: Repo, @@ -164,6 +170,33 @@ defmodule Philomena.Users do |> Repo.isolated_transaction(:serializable) end + def update_name(user, user_params) do + old_name = user.name + + name_change = UserNameChange.changeset(%UserNameChange{user_id: user.id}, user.name) + account = User.name_changeset(user, user_params) + + Multi.new() + |> Multi.insert(:name_change, name_change) + |> Multi.update(:account, account) + |> Repo.isolated_transaction(:serializable) + |> case do + {:ok, %{account: %{name: new_name}}} = result -> + spawn(fn -> + Images.user_name_reindex(old_name, new_name) + Comments.user_name_reindex(old_name, new_name) + Posts.user_name_reindex(old_name, new_name) + Galleries.user_name_reindex(old_name, new_name) + Reports.user_name_reindex(old_name, new_name) + end) + + result + + result -> + result + end + end + def reactivate_user(%User{} = user) do user |> User.reactivate_changeset() diff --git a/lib/philomena/users/ability.ex b/lib/philomena/users/ability.ex index b2a338d6..ab70daa2 100644 --- a/lib/philomena/users/ability.ex +++ b/lib/philomena/users/ability.ex @@ -284,6 +284,12 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{id: id}, :edit_description, %User{id: id}), do: true def can?(%User{id: id}, :edit_title, %User{id: id}), do: true + # Edit their username + def can?(%User{id: id}, :change_username, %User{id: id} = user) do + time_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-1 * 60 * 60 * 24 * 90) + NaiveDateTime.diff(user.last_renamed_at, time_ago) < 0 + end + # View conversations they are involved in def can?(%User{id: id}, :show, %Conversation{to_id: id}), do: true def can?(%User{id: id}, :show, %Conversation{from_id: id}), do: true diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index dd928417..99b11ffe 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -250,6 +250,17 @@ defmodule Philomena.Users.User do |> cast(attrs, [:scratchpad]) end + def name_changeset(user, attrs) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + user + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> put_slug + |> unique_constraints() + |> put_change(:last_renamed_at, now) + end + def avatar_changeset(user, attrs) do user |> cast(attrs, [ diff --git a/lib/philomena_web/controllers/channel_controller.ex b/lib/philomena_web/controllers/channel_controller.ex index 7881b19b..24684dc4 100644 --- a/lib/philomena_web/controllers/channel_controller.ex +++ b/lib/philomena_web/controllers/channel_controller.ex @@ -6,7 +6,9 @@ defmodule PhilomenaWeb.ChannelController do alias Philomena.Repo import Ecto.Query - plug :load_and_authorize_resource, model: Channel, only: [:show, :new, :create, :edit, :update, :delete] + plug :load_and_authorize_resource, + model: Channel, + only: [:show, :new, :create, :edit, :update, :delete] def index(conn, params) do show_nsfw? = conn.cookies["chan_nsfw"] == "true" diff --git a/lib/philomena_web/controllers/registration/name_controller.ex b/lib/philomena_web/controllers/registration/name_controller.ex new file mode 100644 index 00000000..4c089a72 --- /dev/null +++ b/lib/philomena_web/controllers/registration/name_controller.ex @@ -0,0 +1,33 @@ +defmodule PhilomenaWeb.Registration.NameController do + use PhilomenaWeb, :controller + + alias Philomena.Users + + plug PhilomenaWeb.FilterBannedUsersPlug + plug :verify_authorized + + def edit(conn, _params) do + changeset = Users.change_user(conn.assigns.current_user) + + render(conn, "edit.html", title: "Editing Name", changeset: changeset) + end + + def update(conn, %{"user" => user_params}) do + case Users.update_name(conn.assigns.current_user, user_params) do + {:ok, %{account: user}} -> + conn + |> put_flash(:info, "Name successfully updated.") + |> redirect(to: Routes.profile_path(conn, :show, user)) + + {:error, %{account: changeset}} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :change_username, conn.assigns.current_user) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 5a3c33a0..2ce39da1 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -91,6 +91,7 @@ defmodule PhilomenaWeb.Router do # Additional routes for TOTP scope "/registrations", Registration, as: :registration do resources "/totp", TotpController, only: [:edit, :update], singleton: true + resources "/name", NameController, only: [:edit, :update], singleton: true end scope "/sessions", Session, as: :session do diff --git a/lib/philomena_web/templates/pow/registration/edit.html.slime b/lib/philomena_web/templates/pow/registration/edit.html.slime index 2c9f290c..2d65f96d 100644 --- a/lib/philomena_web/templates/pow/registration/edit.html.slime +++ b/lib/philomena_web/templates/pow/registration/edit.html.slime @@ -12,6 +12,11 @@ p ' Looking to change your avatar? = link "Click here!", to: Routes.avatar_path(@conn, :edit) += if can?(@conn, :change_username, @current_user) do + p + ' Looking to change your username? + = link "Click here!", to: Routes.registration_name_path(@conn, :edit) + h3 API Key p ' Your API key is diff --git a/lib/philomena_web/templates/registration/name/edit.html.slime b/lib/philomena_web/templates/registration/name/edit.html.slime new file mode 100644 index 00000000..a7737e76 --- /dev/null +++ b/lib/philomena_web/templates/registration/name/edit.html.slime @@ -0,0 +1,17 @@ +h1 Editing Name + += form_for @changeset, Routes.registration_name_path(@conn, :update), [as: :user], fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + p Enter your new name here. Usernames may only be changed once every 90 days. Please be aware that once you change your name, your previous name will be available for reuse, and someone else may claim it. + + .field + = text_input f, :name, class: "input", placeholder: "Name", required: true + = error_tag f, :name + + .action + = submit "Save", class: "button" + +p = link "Back", to: Routes.pow_registration_path(@conn, :edit) diff --git a/lib/philomena_web/views/registration/name_view.ex b/lib/philomena_web/views/registration/name_view.ex new file mode 100644 index 00000000..4c73ed6b --- /dev/null +++ b/lib/philomena_web/views/registration/name_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Registration.NameView do + use PhilomenaWeb, :view +end