From 8b220775bb3ac6811e230b1a0a05214f074562d2 Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Tue, 17 Dec 2019 00:44:24 -0500 Subject: [PATCH] more admin tools --- lib/philomena/user_downvote_wipe.ex | 72 ++++++++++ lib/philomena/user_wipe.ex | 68 ++++++++++ lib/philomena/users.ex | 18 +++ lib/philomena/users/user.ex | 14 ++ .../admin/user/activation_controller.ex | 32 +++++ .../admin/user/api_key_controller.ex | 24 ++++ .../admin/user/downvote_controller.ex | 26 ++++ .../controllers/admin/user/vote_controller.ex | 26 ++++ .../controllers/admin/user/wipe_controller.ex | 26 ++++ .../controllers/profile_controller.ex | 32 +++++ lib/philomena_web/plugs/reload_user_plug.ex | 9 +- lib/philomena_web/router.ex | 9 +- .../templates/profile/_admin_block.html.slime | 124 ++++++++++++++++++ .../templates/profile/show.html.slime | 5 +- lib/philomena_web/views/profile_view.ex | 6 + 15 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 lib/philomena/user_downvote_wipe.ex create mode 100644 lib/philomena/user_wipe.ex create mode 100644 lib/philomena_web/controllers/admin/user/activation_controller.ex create mode 100644 lib/philomena_web/controllers/admin/user/api_key_controller.ex create mode 100644 lib/philomena_web/controllers/admin/user/downvote_controller.ex create mode 100644 lib/philomena_web/controllers/admin/user/vote_controller.ex create mode 100644 lib/philomena_web/controllers/admin/user/wipe_controller.ex create mode 100644 lib/philomena_web/templates/profile/_admin_block.html.slime diff --git a/lib/philomena/user_downvote_wipe.ex b/lib/philomena/user_downvote_wipe.ex new file mode 100644 index 00000000..6e959c69 --- /dev/null +++ b/lib/philomena/user_downvote_wipe.ex @@ -0,0 +1,72 @@ +defmodule Philomena.UserDownvoteWipe do + alias Philomena.Images.Image + alias Philomena.Images + alias Philomena.ImageVotes.ImageVote + alias Philomena.ImageFaves.ImageFave + alias Philomena.Repo + import Ecto.Query + + def perform(user, upvotes_and_faves_too \\ false) do + ImageVote + |> where(user_id: ^user.id, up: false) + |> in_batches(fn queryable, image_ids -> + Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids)) + Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [downvotes_count: -1, score: 1]) + Images.reindex_images(image_ids) + + # Allow time for indexing to catch up + :timer.sleep(:timer.seconds(10)) + end) + + if upvotes_and_faves_too do + ImageVote + |> where(user_id: ^user.id, up: true) + |> in_batches(fn queryable, image_ids -> + Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids)) + Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [upvotes_count: -1, score: -1]) + Images.reindex_images(image_ids) + + :timer.sleep(:timer.seconds(10)) + end) + + ImageFave + |> where(user_id: ^user.id) + |> in_batches(fn queryable, image_ids -> + Repo.delete_all(where(queryable, [iv], iv.image_id in ^image_ids)) + Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [faves_count: -1]) + Images.reindex_images(image_ids) + + :timer.sleep(:timer.seconds(10)) + end) + end + end + + # todo: extract this to its own module somehow + defp in_batches(queryable, mapper) do + queryable = order_by(queryable, asc: :image_id) + + ids = + queryable + |> select([q], q.image_id) + |> limit(1000) + |> Repo.all() + + queryable + |> in_batches(mapper, 1000, ids) + end + + defp in_batches(_queryable, _mapper, _batch_size, []), do: nil + + defp in_batches(queryable, mapper, batch_size, ids) do + mapper.(exclude(queryable, :order_by), ids) + + ids = + queryable + |> where([q], q.image_id > ^Enum.max(ids)) + |> select([q], q.image_id) + |> limit(^batch_size) + |> Repo.all() + + in_batches(queryable, mapper, batch_size, ids) + end +end diff --git a/lib/philomena/user_wipe.ex b/lib/philomena/user_wipe.ex new file mode 100644 index 00000000..c227e6bd --- /dev/null +++ b/lib/philomena/user_wipe.ex @@ -0,0 +1,68 @@ +defmodule Philomena.UserWipe do + @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32} + @wipe_fp "ffff" + + alias Philomena.Comments.Comment + alias Philomena.Images.Image + alias Philomena.Posts.Post + alias Philomena.Reports.Report + alias Philomena.SourceChanges.SourceChange + alias Philomena.TagChanges.TagChange + alias Philomena.UserIps.UserIp + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.Users.User + alias Philomena.Repo + import Ecto.Query + + def perform(user) do + random_hex = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + + for schema <- [Comment, Image, Post, Report, SourceChange, TagChange] do + schema + |> where(user_id: ^user.id) + |> in_batches(&Repo.update_all(&1, set: [ip: @wipe_ip, fingerprint: @wipe_fp])) + end + + UserIp + |> where(user_id: ^user.id) + |> Repo.delete_all() + + UserFingerprint + |> where(user_id: ^user.id) + |> Repo.delete_all() + + User + |> where(id: ^user.id) + |> Repo.update_all(set: [email: "deactivated#{random_hex}@example.com", current_sign_in_ip: @wipe_ip, last_sign_in_ip: @wipe_ip]) + end + + defp in_batches(queryable, mapper) do + queryable = order_by(queryable, asc: :id) + + ids = + queryable + |> select([q], q.id) + |> limit(1000) + |> Repo.all() + + in_batches(queryable, mapper, 1000, ids) + end + + defp in_batches(_queryable, _mapper, _batch_size, []), do: nil + + defp in_batches(queryable, mapper, batch_size, ids) do + queryable + |> where([q], q.id in ^ids) + |> exclude(:order_by) + |> mapper.() + + ids = + queryable + |> where([q], q.id > ^Enum.max(ids)) + |> select([q], q.id) + |> limit(^batch_size) + |> Repo.all() + + in_batches(queryable, mapper, batch_size, ids) + end +end diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index 90797e00..07bb6e28 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -149,6 +149,24 @@ defmodule Philomena.Users do |> Repo.isolated_transaction(:serializable) end + def reactivate_user(%User{} = user) do + user + |> User.reactivate_changeset() + |> Repo.update() + end + + def deactivate_user(moderator, %User{} = user) do + user + |> User.deactivate_changeset(moderator) + |> Repo.update() + end + + def reset_api_key(%User{} = user) do + user + |> User.api_key_changeset() + |> Repo.update() + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking user changes. diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 6018f18a..383244cb 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -249,6 +249,20 @@ defmodule Philomena.Users.User do change(user, watched_tag_ids: watched_tag_ids) end + def reactivate_changeset(user) do + change(user, deleted_at: nil, deleted_by_user_id: nil) + end + + def deactivate_changeset(user, moderator) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + change(user, deleted_at: now, deleted_by_user_id: moderator.id) + end + + def api_key_changeset(user) do + put_api_key(user) + end + def create_totp_secret_changeset(user) do secret = :crypto.strong_rand_bytes(15) |> Base.encode32() data = Philomena.Users.Encryptor.encrypt_model(secret) diff --git a/lib/philomena_web/controllers/admin/user/activation_controller.ex b/lib/philomena_web/controllers/admin/user/activation_controller.ex new file mode 100644 index 00000000..c641729b --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/activation_controller.ex @@ -0,0 +1,32 @@ +defmodule PhilomenaWeb.Admin.User.ActivationController do + use PhilomenaWeb, :controller + + alias Philomena.Users.User + alias Philomena.Users + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def create(conn, _params) do + {:ok, user} = Users.reactivate_user(conn.assigns.user) + + conn + |> put_flash(:info, "User was reactivated.") + |> redirect(to: Routes.profile_path(conn, :show, user)) + end + + def delete(conn, _params) do + {:ok, user} = Users.deactivate_user(conn.assigns.current_user, conn.assigns.user) + + conn + |> put_flash(:info, "User was deactivated.") + |> redirect(to: Routes.profile_path(conn, :show, user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/admin/user/api_key_controller.ex b/lib/philomena_web/controllers/admin/user/api_key_controller.ex new file mode 100644 index 00000000..f38b1f77 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/api_key_controller.ex @@ -0,0 +1,24 @@ +defmodule PhilomenaWeb.Admin.User.ApiKeyController do + use PhilomenaWeb, :controller + + alias Philomena.Users.User + alias Philomena.Users + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def delete(conn, _params) do + {:ok, user} = Users.reset_api_key(conn.assigns.user) + + conn + |> put_flash(:info, "API token successfully reset.") + |> redirect(to: Routes.profile_path(conn, :show, user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/admin/user/downvote_controller.ex b/lib/philomena_web/controllers/admin/user/downvote_controller.ex new file mode 100644 index 00000000..06804e4f --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/downvote_controller.ex @@ -0,0 +1,26 @@ +defmodule PhilomenaWeb.Admin.User.DownvoteController do + use PhilomenaWeb, :controller + + alias Philomena.UserDownvoteWipe + alias Philomena.Users.User + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def delete(conn, _params) do + spawn fn -> + UserDownvoteWipe.perform(conn.assigns.user) + end + + conn + |> put_flash(:info, "Downvote wipe started.") + |> redirect(to: Routes.profile_path(conn, :show, conn.assigns.user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/admin/user/vote_controller.ex b/lib/philomena_web/controllers/admin/user/vote_controller.ex new file mode 100644 index 00000000..b5c95085 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/vote_controller.ex @@ -0,0 +1,26 @@ +defmodule PhilomenaWeb.Admin.User.VoteController do + use PhilomenaWeb, :controller + + alias Philomena.UserDownvoteWipe + alias Philomena.Users.User + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def delete(conn, _params) do + spawn fn -> + UserDownvoteWipe.perform(conn.assigns.user, true) + end + + conn + |> put_flash(:info, "Vote and fave wipe started.") + |> redirect(to: Routes.profile_path(conn, :show, conn.assigns.user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/admin/user/wipe_controller.ex b/lib/philomena_web/controllers/admin/user/wipe_controller.ex new file mode 100644 index 00000000..ed4732b8 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/wipe_controller.ex @@ -0,0 +1,26 @@ +defmodule PhilomenaWeb.Admin.User.WipeController do + use PhilomenaWeb, :controller + + alias Philomena.UserWipe + alias Philomena.Users.User + + plug :verify_authorized + plug :load_resource, model: User, id_name: "user_id", id_field: "slug", persisted: true + + def create(conn, _params) do + spawn fn -> + UserWipe.perform(conn.assigns.user) + end + + conn + |> put_flash(:info, "PII wipe started, please verify and then deactivate the account as necessary.") + |> redirect(to: Routes.profile_path(conn, :show, conn.assigns.user)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index 325c6939..dc8d1eda 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -11,12 +11,15 @@ defmodule PhilomenaWeb.ProfileController do alias Philomena.Comments.Comment alias Philomena.Interactions alias Philomena.Tags.Tag + alias Philomena.UserIps.UserIp + alias Philomena.UserFingerprints.UserFingerprint alias Philomena.Repo import Ecto.Query plug :load_and_authorize_resource, model: User, only: :show, id_field: "slug", preload: [ awards: :badge, public_links: :tag, verified_links: :tag, commission: [sheet_image: :tags, items: [example_image: :tags]] ] + plug :set_admin_metadata def show(conn, _params) do current_user = conn.assigns.current_user @@ -190,4 +193,33 @@ defmodule PhilomenaWeb.ProfileController do images end + + defp set_admin_metadata(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, User) do + true -> + user = Repo.preload(conn.assigns.user, :current_filter) + filter = user.current_filter + last_ip = + UserIp + |> where(user_id: ^user.id) + |> order_by(desc: :updated_at) + |> limit(1) + |> Repo.one() + + last_fp = + UserFingerprint + |> where(user_id: ^user.id) + |> order_by(desc: :updated_at) + |> limit(1) + |> Repo.one() + + conn + |> assign(:filter, filter) + |> assign(:last_ip, last_ip) + |> assign(:last_fp, last_fp) + + _false -> + conn + end + end end diff --git a/lib/philomena_web/plugs/reload_user_plug.ex b/lib/philomena_web/plugs/reload_user_plug.ex index 72f12de7..f154ff6a 100644 --- a/lib/philomena_web/plugs/reload_user_plug.ex +++ b/lib/philomena_web/plugs/reload_user_plug.ex @@ -6,6 +6,7 @@ defmodule PhilomenaWeb.ReloadUserPlug do alias Philomena.UserIps.UserIp alias Philomena.UserFingerprints.UserFingerprint alias Philomena.Repo + import Ecto.Query def init(opts), do: opts @@ -33,20 +34,24 @@ defmodule PhilomenaWeb.ReloadUserPlug do fp = conn.cookies["_ses"] if ip do + update = update(UserIp, inc: [uses: 1], set: [updated_at: fragment("now()")]) + Repo.insert_all( UserIp, [%{user_id: user.id, ip: ip, uses: 1}], conflict_target: [:user_id, :ip], - on_conflict: [inc: [uses: 1]] + on_conflict: update ) end if fp do + update = update(UserFingerprint, inc: [uses: 1], set: [updated_at: fragment("now()")]) + Repo.insert_all( UserFingerprint, [%{user_id: user.id, fingerprint: fp, uses: 1}], conflict_target: [:user_id, :fingerprint], - on_conflict: [inc: [uses: 1]] + on_conflict: update ) end end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index d979e058..7adc356d 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -224,11 +224,18 @@ defmodule PhilomenaWeb.Router do resources "/mod_notes", ModNoteController, except: [:show] resources "/users", UserController, only: [:index, :edit, :update] do resources "/avatar", User.AvatarController, only: [:delete], singleton: true + resources "/activation", User.ActivationController, only: [:create, :delete], singleton: true + resources "/api_key", User.ApiKeyController, only: [:delete], singleton: true + resources "/downvotes", User.DownvoteController, only: [:delete], singleton: true + resources "/votes", User.VoteController, only: [:delete], singleton: true + resources "/wipe", User.WipeController, only: [:create], singleton: true end resources "/batch/tags", Batch.TagController, only: [:update], singleton: true + scope "/donations", Donation, as: :donation do + resources "/user", UserController, only: [:show] + end resources "/donations", DonationController, only: [:index, :create] - resources "/donations/user", Donation.UserController, only: [:show] end resources "/duplicate_reports", DuplicateReportController, only: [] do diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime new file mode 100644 index 00000000..a30cc9d2 --- /dev/null +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -0,0 +1,124 @@ +.block--fixed + i.fa.fa-fw.fa-calendar> + ' Account created + = @user.created_at + + br + i.fa.fa-fw.fa-filter> + ' Current Filter: + = if @filter do + = link @filter.name, to: Routes.filter_path(@conn, :show, @filter) + - else + em + ' (none) + + br + i.far.fa-fw.fa-clock> + ' Last seen + = if @last_ip do + => pretty_time(@last_ip.updated_at) + ' from + => link_to_ip(@conn, @last_ip.ip) + + = if @last_fp do + => link_to_fingerprint(@conn, @last_fp.fingerprint) + - else + em + ' (never) + + br + ' Two factor auth: + strong = enabled_text(@user.otp_required_for_login) + +br + +a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__options__toggle" title="Toggle Controls" + i.fa.fa-fw.fa-bars + span Toggle Controls + +.profile-top__options.js-admin__options__toggle.hidden + ul.profile-admin__options__column + li + = link to: Routes.profile_detail_path(@conn, :index, @user) do + i.fa.fa-fw.fa-eye + span.admin__button View Details + li + = link to: Routes.search_path(@conn, :index, q: "upvoted_by_id:#{@user.id}") do + i.fa.fa-fw.fa-arrow-up + span.admin__button Upvotes + li + = link to: Routes.search_path(@conn, :index, q: "downvoted_by_id:#{@user.id}") do + i.fa.fa-fw.fa-arrow-down + span.admin__button Downvotes + li + = link to: Routes.search_path(@conn, :index, q: "hidden_by_id:#{@user.id}") do + i.fa.fa-fw.fa-eye-slash + span.admin__button Hidden Images + li + = link to: Routes.admin_report_path(@conn, :index, rq: "user_id:#{@user.id}") do + i.fa.fa-fw.fa-exclamation + span.admin__button Reports + li + = link to: Routes.profile_ip_history_path(@conn, :index, @user) do + i.fab.fa-fw.fa-internet-explorer + span.admin__button IP History + li + = link to: Routes.profile_fp_history_path(@conn, :index, @user) do + i.fa.fa-fw.fa-desktop + span.admin__button FP History + li + = link to: Routes.profile_alias_path(@conn, :index, @user) do + i.fa.fa-fw.fa-users + span.admin__button Potential Aliases + + ul.profile-admin__options__column + + li + = link to: Routes.admin_user_path(@conn, :edit, @user) do + i.fas.fa-fw.fa-edit + span.admin__button Edit User + + = if @user.deleted_at do + li + = link to: Routes.admin_user_activation_path(@conn, :create, @user), data: [confirm: "Are you really, really sure?", method: "post"] do + i.fa.fa-fw.fa-check + span.admin__button Reactivate Account + - else + li + = link to: Routes.admin_user_activation_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do + i.fa.fa-fw.fa-times + span.admin__button Deactivate Account + li + = link to: Routes.admin_donation_user_path(@conn, :show, @user) do + i.fas.fa-fw.fa-dollar-sign + span.admin__button Donations + + li + = link to: Routes.profile_user_link_path(@conn, :new, @user) do + i.fa.fa-fw.fa-link + span.admin__button Add User Link + + li + = link to: Routes.admin_user_vote_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do + i.far.fa-fw.fa-file-excel + span.admin__button Remove All Votes/Faves + + li + = link to: Routes.admin_user_downvote_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do + i.fa.fa-fw.fa-arrow-down + span.admin__button Remove All Downvotes + + li + = link to: Routes.admin_user_ban_path(@conn, :new, username: @user.name) do + i.fa.fa-fw.fa-ban + span.admin__button Ban this sucker + + li + = link to: Routes.admin_user_wipe_path(@conn, :create, @user), data: [confirm: "This is irreversible, destroying all identifying information including email. Are you sure?", method: "post"] do + i.fas.fa-fw.fa-eraser + span.admin__button Wipe PII + + li + = link to: Routes.admin_user_api_key_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do + i.fas.fa-fw.fa-key + span.admin__button Reset API key diff --git a/lib/philomena_web/templates/profile/show.html.slime b/lib/philomena_web/templates/profile/show.html.slime index 516e4593..ad8c0b36 100644 --- a/lib/philomena_web/templates/profile/show.html.slime +++ b/lib/philomena_web/templates/profile/show.html.slime @@ -22,8 +22,6 @@ li = link("Send message", to: Routes.conversation_path(@conn, :new, recipient: @user.name)) li = link("Our conversations", to: Routes.conversation_path(@conn, :index, with: @user.id)) li = link("Report this user", to: Routes.profile_report_path(@conn, :new, @user)) - = if can_ban?(@conn) do - li = link("Ban this sucker", to: Routes.admin_user_ban_path(@conn, :new, username: @user.name)) ul.profile-top__options__column li = link("Uploads", to: Routes.search_path(@conn, :index, q: "uploader_id:#{@user.id}")) @@ -37,6 +35,9 @@ li = link("Tag changes", to: Routes.profile_tag_change_path(@conn, :index, @user)) li = link("Source changes", to: Routes.profile_source_change_path(@conn, :index, @user)) += if can_index_user?(@conn) do + = render PhilomenaWeb.ProfileView, "_admin_block.html", assigns + = if (current?(@user, @conn.assigns.current_user) or can?(@conn, :index, UserBan)) and Enum.any?(@bans) do .block .block__header diff --git a/lib/philomena_web/views/profile_view.ex b/lib/philomena_web/views/profile_view.ex index 48dd36ad..028818d6 100644 --- a/lib/philomena_web/views/profile_view.ex +++ b/lib/philomena_web/views/profile_view.ex @@ -56,6 +56,12 @@ defmodule PhilomenaWeb.ProfileView do def can_ban?(conn), do: can?(conn, :index, Philomena.Bans.User) + def can_index_user?(conn), + do: can?(conn, :index, Philomena.Users.User) + + def enabled_text(true), do: "Enabled" + def enabled_text(_else), do: "Disabled" + def user_abbrv(conn, %{name: name} = user) do abbrv = String.upcase(initials_abbrv(name) || uppercase_abbrv(name) || first_letters_abbrv(name)) abbrv = "(" <> abbrv <> ")"