diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 605c6f0a..7d7b27ea 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -24,6 +24,7 @@ defmodule Philomena.Images do alias Philomena.SourceChanges.SourceChange alias Philomena.Notifications.Notification alias Philomena.NotificationWorker + alias Philomena.TagChanges.Limits alias Philomena.TagChanges.TagChange alias Philomena.Tags alias Philomena.UserStatistics @@ -417,6 +418,9 @@ defmodule Philomena.Images do error end end) + |> Multi.run(:check_limits, fn _repo, %{image: {image, _added, _removed}} -> + check_tag_change_limits_before_commit(image, attribution) + end) |> Multi.run(:added_tag_changes, fn repo, %{image: {image, added_tags, _removed}} -> tag_changes = added_tags @@ -460,6 +464,43 @@ defmodule Philomena.Images do {:ok, count} end) |> Repo.transaction() + |> case do + {:ok, %{image: {image, _added, _removed}}} = res -> + update_tag_change_limits_after_commit(image, attribution) + + res + + err -> + err + end + end + + defp check_tag_change_limits_before_commit(image, attribution) do + tag_changed_count = length(image.added_tags) + length(image.removed_tags) + rating_changed = image.ratings_changed + user = attribution[:user] + ip = attribution[:ip] + + cond do + Limits.limited_for_tag_count?(user, ip, tag_changed_count) -> + {:error, :limit_exceeded} + + rating_changed and Limits.limited_for_rating_count?(user, ip) -> + {:error, :limit_exceeded} + + true -> + {:ok, 0} + end + end + + def update_tag_change_limits_after_commit(image, attribution) do + rating_changed_count = if(image.ratings_changed, do: 1, else: 0) + tag_changed_count = length(image.added_tags) + length(image.removed_tags) + user = attribution[:user] + ip = attribution[:ip] + + Limits.update_tag_count_after_update(user, ip, tag_changed_count) + Limits.update_rating_count_after_update(user, ip, rating_changed_count) end defp tag_change_attributes(attribution, image, tag, added, user) do diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 83ce9409..3e9c889a 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -96,6 +96,7 @@ defmodule Philomena.Images.Image do field :added_tags, {:array, :any}, default: [], virtual: true field :removed_sources, {:array, :any}, default: [], virtual: true field :added_sources, {:array, :any}, default: [], virtual: true + field :ratings_changed, :boolean, default: false, virtual: true field :uploaded_image, :string, virtual: true field :removed_image, :string, virtual: true diff --git a/lib/philomena/images/tag_validator.ex b/lib/philomena/images/tag_validator.ex index 887b5daa..8ffe3bdb 100644 --- a/lib/philomena/images/tag_validator.ex +++ b/lib/philomena/images/tag_validator.ex @@ -5,7 +5,20 @@ defmodule Philomena.Images.TagValidator do def validate_tags(changeset) do tags = changeset |> get_field(:tags) - validate_tag_input(changeset, tags) + changeset + |> validate_tag_input(tags) + |> set_rating_changed() + end + + defp set_rating_changed(changeset) do + added_tags = changeset |> get_field(:added_tags) |> extract_names() + removed_tags = changeset |> get_field(:removed_tags) |> extract_names() + ratings = all_ratings() + + added_ratings = MapSet.intersection(ratings, added_tags) |> MapSet.size() + removed_ratings = MapSet.intersection(ratings, removed_tags) |> MapSet.size() + + put_change(changeset, :ratings_changed, added_ratings + removed_ratings > 0) end defp validate_tag_input(changeset, tags) do @@ -108,6 +121,13 @@ defmodule Philomena.Images.TagValidator do |> MapSet.new() end + defp all_ratings do + safe_rating() + |> MapSet.union(sexual_ratings()) + |> MapSet.union(horror_ratings()) + |> MapSet.union(gross_rating()) + end + defp safe_rating, do: MapSet.new(["safe"]) defp sexual_ratings, do: MapSet.new(["suggestive", "questionable", "explicit"]) defp horror_ratings, do: MapSet.new(["semi-grimdark", "grimdark"]) diff --git a/lib/philomena/tag_changes/limits.ex b/lib/philomena/tag_changes/limits.ex new file mode 100644 index 00000000..496ef2c2 --- /dev/null +++ b/lib/philomena/tag_changes/limits.ex @@ -0,0 +1,109 @@ +defmodule Philomena.TagChanges.Limits do + @moduledoc """ + Tag change limits for anonymous users. + """ + + @tag_changes_per_ten_minutes 50 + @rating_changes_per_ten_minutes 1 + @ten_minutes_in_seconds 10 * 60 + + @doc """ + Determine if the current user and IP can make any tag changes at all. + + The user may be limited due to making more than 50 tag changes in the past 10 minutes. + Should be used in tandem with `update_tag_count_after_update/3`. + + ## Examples + + iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}) + false + + iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}, 72) + true + + """ + def limited_for_tag_count?(user, ip, additional \\ 0) do + check_limit(user, tag_count_key_for_ip(ip), @tag_changes_per_ten_minutes, additional) + end + + @doc """ + Determine if the current user and IP can make rating tag changes. + + The user may be limited due to making more than one rating tag change in the past 10 minutes. + Should be used in tandem with `update_rating_count_after_update/3`. + + ## Examples + + iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}) + false + + iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}, 2) + true + + """ + def limited_for_rating_count?(user, ip) do + check_limit(user, rating_count_key_for_ip(ip), @rating_changes_per_ten_minutes, 0) + end + + @doc """ + Post-transaction update for successful tag changes. + + Should be used in tandem with `limited_for_tag_count?/2`. + + ## Examples + + iex> update_tag_count_after_update(%User{}, %Postgrex.INET{}, 25) + :ok + + """ + def update_tag_count_after_update(user, ip, amount) do + increment_counter(user, tag_count_key_for_ip(ip), amount, @ten_minutes_in_seconds) + end + + @doc """ + Post-transaction update for successful rating tag changes. + + Should be used in tandem with `limited_for_rating_count?/2`. + + ## Examples + + iex> update_rating_count_after_update(%User{}, %Postgrex.INET{}, 1) + :ok + + """ + def update_rating_count_after_update(user, ip, amount) do + increment_counter(user, rating_count_key_for_ip(ip), amount, @ten_minutes_in_seconds) + end + + defp check_limit(user, key, limit, additional) do + if considered_for_limit?(user) do + amt = Redix.command!(:redix, ["GET", key]) || 0 + amt + additional >= limit + else + false + end + end + + defp increment_counter(user, key, amount, expiration) do + if considered_for_limit?(user) do + Redix.pipeline!(:redix, [ + ["INCRBY", key, amount], + ["EXPIRE", key, expiration] + ]) + end + + :ok + end + + defp considered_for_limit?(user) do + is_nil(user) or not user.verified + end + + defp tag_count_key_for_ip(ip) do + "rltcn:#{ip}" + end + + defp rating_count_key_for_ip(ip) do + "rltcr:#{ip}" + end +end diff --git a/lib/philomena_web/controllers/image/tag_controller.ex b/lib/philomena_web/controllers/image/tag_controller.ex index 2bae9731..468864c9 100644 --- a/lib/philomena_web/controllers/image/tag_controller.ex +++ b/lib/philomena_web/controllers/image/tag_controller.ex @@ -8,6 +8,7 @@ defmodule PhilomenaWeb.Image.TagController do alias Philomena.Images alias Philomena.Tags alias Philomena.Repo + alias Plug.Conn import Ecto.Query plug PhilomenaWeb.LimitPlug, @@ -88,6 +89,18 @@ defmodule PhilomenaWeb.Image.TagController do image: image, changeset: changeset ) + + {:error, :check_limits, _error, _} -> + conn + |> put_flash(:error, "Too many tags changed. Change fewer tags or try again later.") + |> Conn.send_resp(:multiple_choices, "") + |> Conn.halt() + + _err -> + conn + |> put_flash(:error, "Failed to update tags!") + |> Conn.send_resp(:multiple_choices, "") + |> Conn.halt() end end end