diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index ab3fc434..db285491 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -215,6 +215,18 @@ defmodule Philomena.Users do |> Repo.update() end + def force_filter(%User{} = user, user_params) do + user + |> User.force_filter_changeset(user_params) + |> Repo.update() + end + + def unforce_filter(%User{} = user) do + user + |> User.unforce_filter_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 99b11ffe..f71bcd2f 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -48,6 +48,7 @@ defmodule Philomena.Users.User do many_to_many :roles, Role, join_through: "users_roles", on_replace: :delete belongs_to :current_filter, Filter + belongs_to :forced_filter, Filter belongs_to :deleted_by_user, User # Authentication @@ -308,6 +309,16 @@ defmodule Philomena.Users.User do put_api_key(user) end + def force_filter_changeset(user, params) do + user + |> cast(params, [:forced_filter_id]) + |> foreign_key_constraint(:forced_filter_id) + end + + def unforce_filter_changeset(user) do + change(user, forced_filter_id: nil) + 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/force_filter_controller.ex b/lib/philomena_web/controllers/admin/user/force_filter_controller.ex new file mode 100644 index 00000000..e32d9dfd --- /dev/null +++ b/lib/philomena_web/controllers/admin/user/force_filter_controller.ex @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.Admin.User.ForceFilterController 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 new(conn, _params) do + changeset = Users.change_user(conn.assigns.user) + + render(conn, "new.html", changeset: changeset, title: "Forcing filter for user") + end + + def create(conn, %{"user" => user_params}) do + {:ok, user} = Users.force_filter(conn.assigns.user, user_params) + + conn + |> put_flash(:info, "Filter was forced.") + |> redirect(to: Routes.profile_path(conn, :show, user)) + end + + def delete(conn, _params) do + {:ok, user} = Users.unforce_filter(conn.assigns.current_user) + + conn + |> put_flash(:info, "Forced filter was removed.") + |> 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/image/comment_controller.ex b/lib/philomena_web/controllers/image/comment_controller.ex index c35e56ea..4e0399af 100644 --- a/lib/philomena_web/controllers/image/comment_controller.ex +++ b/lib/philomena_web/controllers/image/comment_controller.ex @@ -16,8 +16,9 @@ defmodule PhilomenaWeb.Image.CommentController do edit: :create_comment, update: :create_comment - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true, preload: [:tags] plug :verify_authorized when action in [:show] + plug PhilomenaWeb.FilterForcedUsersPlug when action in [:create, :edit, :update] # Undo the previous private parameter screwery plug PhilomenaWeb.LoadCommentPlug, [param: "id", show_hidden: true] when action in [:show] diff --git a/lib/philomena_web/controllers/image/fave_controller.ex b/lib/philomena_web/controllers/image/fave_controller.ex index 3f6212cc..033f1f27 100644 --- a/lib/philomena_web/controllers/image/fave_controller.ex +++ b/lib/philomena_web/controllers/image/fave_controller.ex @@ -8,7 +8,8 @@ defmodule PhilomenaWeb.Image.FaveController do plug PhilomenaWeb.FilterBannedUsersPlug plug PhilomenaWeb.CanaryMapPlug, create: :vote, delete: :vote - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true, preload: [:tags] + plug PhilomenaWeb.FilterForcedUsersPlug def create(conn, _params) do user = conn.assigns.current_user diff --git a/lib/philomena_web/controllers/image/vote_controller.ex b/lib/philomena_web/controllers/image/vote_controller.ex index e13b9673..8b39748a 100644 --- a/lib/philomena_web/controllers/image/vote_controller.ex +++ b/lib/philomena_web/controllers/image/vote_controller.ex @@ -8,7 +8,8 @@ defmodule PhilomenaWeb.Image.VoteController do plug PhilomenaWeb.FilterBannedUsersPlug plug PhilomenaWeb.CanaryMapPlug, create: :vote, delete: :vote - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true, preload: [:tags] + plug PhilomenaWeb.FilterForcedUsersPlug def create(conn, params) do user = conn.assigns.current_user diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index 780b354c..d78abc12 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -223,8 +223,9 @@ defmodule PhilomenaWeb.ProfileController do 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) + user = Repo.preload(conn.assigns.user, [:current_filter, :forced_filter]) filter = user.current_filter + forced = user.forced_filter last_ip = UserIp @@ -242,6 +243,7 @@ defmodule PhilomenaWeb.ProfileController do conn |> assign(:filter, filter) + |> assign(:forced, forced) |> assign(:last_ip, last_ip) |> assign(:last_fp, last_fp) diff --git a/lib/philomena_web/plugs/current_filter_plug.ex b/lib/philomena_web/plugs/current_filter_plug.ex index 296061e7..1d1fef15 100644 --- a/lib/philomena_web/plugs/current_filter_plug.ex +++ b/lib/philomena_web/plugs/current_filter_plug.ex @@ -12,22 +12,25 @@ defmodule PhilomenaWeb.CurrentFilterPlug do conn = conn |> fetch_session() user = conn |> Plug.current_user() - filter = + {filter, forced_filter} = if user do - user - |> Repo.preload(:current_filter) - |> maybe_set_default_filter() - |> Map.get(:current_filter) + user = + user + |> Repo.preload([:current_filter, :forced_filter]) + |> maybe_set_default_filter() + + {user.current_filter, user.forced_filter} else filter_id = conn |> get_session(:filter_id) filter = if filter_id, do: Repo.get(Filter, filter_id) - filter || Filters.default_filter() + {filter || Filters.default_filter(), nil} end conn |> assign(:current_filter, filter) + |> assign(:forced_filter, forced_filter) end defp maybe_set_default_filter(%{current_filter: nil} = user) do diff --git a/lib/philomena_web/plugs/filter_forced_users_plug.ex b/lib/philomena_web/plugs/filter_forced_users_plug.ex new file mode 100644 index 00000000..6a102e90 --- /dev/null +++ b/lib/philomena_web/plugs/filter_forced_users_plug.ex @@ -0,0 +1,59 @@ +defmodule PhilomenaWeb.FilterForcedUsersPlug do + @moduledoc """ + Halts the request pipeline if the current image belongs to the conn's + "forced filter". + """ + + import Phoenix.Controller + import Plug.Conn + alias Philomena.Search.String, as: SearchString + alias Philomena.Search.Evaluator + alias Philomena.Images.Query + alias PhilomenaWeb.ImageView + + def init(_opts) do + [] + end + + def call(conn, _opts) do + maybe_fetch_forced(conn, conn.assigns.forced_filter) + end + + defp maybe_fetch_forced(conn, nil), do: conn + defp maybe_fetch_forced(conn, forced) do + maybe_halt(conn, matches_filter?(conn.assigns.current_user, conn.assigns.image, forced)) + end + + defp maybe_halt(conn, false), do: conn + defp maybe_halt(conn, true) do + conn + |> put_flash(:error, "You have been blocked from performing this action on this image.") + |> redirect(external: conn.assigns.referrer) + |> halt() + end + + defp matches_filter?(user, image, filter) do + matches_tag_filter?(image, filter.hidden_tag_ids) or + matches_complex_filter?(user, image, filter.hidden_complex_str) + end + + defp matches_tag_filter?(image, tag_ids) do + image.tags + |> MapSet.new(& &1.id) + |> MapSet.intersection(MapSet.new(tag_ids)) + |> Enum.any?() + end + + defp matches_complex_filter?(user, image, search_string) do + image + |> ImageView.image_filter_data() + |> Evaluator.hits?(compile_filter(user, search_string)) + end + + defp compile_filter(user, search_string) do + case Query.compile(user, SearchString.normalize(search_string)) do + {:ok, query} -> query + _error -> %{match_all: %{}} + end + end +end diff --git a/lib/philomena_web/plugs/image_filter_plug.ex b/lib/philomena_web/plugs/image_filter_plug.ex index b313d8c6..9562c813 100644 --- a/lib/philomena_web/plugs/image_filter_plug.ex +++ b/lib/philomena_web/plugs/image_filter_plug.ex @@ -11,11 +11,19 @@ defmodule PhilomenaWeb.ImageFilterPlug do # Assign current filter def call(conn, _opts) do user = conn |> Plug.current_user() - filter = conn.assigns[:current_filter] + filter = defaults(conn.assigns[:current_filter]) + forced = defaults(conn.assigns[:forced_filter]) tag_exclusion = %{terms: %{tag_ids: filter.hidden_tag_ids}} - query_exclusion = invalid_filter_guard(user, filter.hidden_complex_str) query_spoiler = invalid_filter_guard(user, filter.spoilered_complex_str) + query_exclusion = %{ + bool: %{ + should: [ + invalid_filter_guard(user, filter.hidden_complex_str), + invalid_filter_guard(user, forced.hidden_complex_str) + ] + } + } query = %{ bool: %{ @@ -29,6 +37,18 @@ defmodule PhilomenaWeb.ImageFilterPlug do |> assign(:compiled_filter, query) end + defp defaults(nil) do + %{ + hidden_tag_ids: [], + hidden_complex_str: nil, + spoilered_complex_str: nil + } + end + + defp defaults(filter) do + filter + end + defp invalid_filter_guard(user, search_string) do case Query.compile(user, normalize(search_string)) do {:ok, query} -> query diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 221c8692..615a8b74 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -366,6 +366,7 @@ defmodule PhilomenaWeb.Router do resources "/downvotes", User.DownvoteController, only: [:delete], singleton: true resources "/votes", User.VoteController, only: [:delete], singleton: true resources "/wipe", User.WipeController, only: [:create], singleton: true + resources "/force_filter", User.ForceFilterController, only: [:new, :create, :delete], singleton: true end resources "/batch/tags", Batch.TagController, only: [:update], singleton: true diff --git a/lib/philomena_web/templates/admin/user/force_filter/new.html.slime b/lib/philomena_web/templates/admin/user/force_filter/new.html.slime new file mode 100644 index 00000000..9d214dd1 --- /dev/null +++ b/lib/philomena_web/templates/admin/user/force_filter/new.html.slime @@ -0,0 +1,9 @@ +h1 + ' Force-assigning a filter for user + = @user.name + += form_for @changeset, Routes.admin_user_force_filter_path(@conn, :create, @user), [method: "post"], fn f -> + .field + => text_input f, :forced_filter_id, placeholder: "Filter ID", class: "input", required: true + .field + = submit "Force", class: "button button--state-primary" diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime index c257639f..d010858e 100644 --- a/lib/philomena_web/templates/profile/_admin_block.html.slime +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -12,6 +12,12 @@ em ' (none) + = if @forced do + br + i.fa.fa-fw.fa-filter> + strong.comment_deleted> Forced Filter: + = link @forced.name, to: Routes.filter_path(@conn, :show, @filter) + br i.far.fa-fw.fa-clock> ' Last seen @@ -78,6 +84,17 @@ a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__optio i.fas.fa-fw.fa-edit span.admin__button Edit User + li + = link to: Routes.admin_user_force_filter_path(@conn, :new, @user) do + i.fas.faw-fw.fa-filter + span.admin__button Force Filter + + = if @forced do + li + = link to: Routes.admin_user_force_filter_path(@conn, :delete, @user), data: [confirm: "Are you really, really sure?", method: "delete"] do + i.fas.faw-fw.fa-filter + span.admin__button Remove Force Filter + = 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 diff --git a/lib/philomena_web/views/admin/user/force_filter_view.ex b/lib/philomena_web/views/admin/user/force_filter_view.ex new file mode 100644 index 00000000..1de9ad08 --- /dev/null +++ b/lib/philomena_web/views/admin/user/force_filter_view.ex @@ -0,0 +1,3 @@ +defmodule PhilomenaWeb.Admin.User.ForceFilterView do + use PhilomenaWeb, :view +end diff --git a/lib/philomena_web/views/image_view.ex b/lib/philomena_web/views/image_view.ex index f0d8a8b3..0295dbd4 100644 --- a/lib/philomena_web/views/image_view.ex +++ b/lib/philomena_web/views/image_view.ex @@ -216,7 +216,7 @@ defmodule PhilomenaWeb.ImageView do defp thumb_format(_, :rendered, _download), do: "png" defp thumb_format(format, _name, _download), do: format - defp image_filter_data(image) do + def image_filter_data(image) do %{ id: image.id, "namespaced_tags.name": String.split(image.tag_list_plus_alias_cache || "", ", "), diff --git a/priv/repo/migrations/20200607000511_add_forced_filter.exs b/priv/repo/migrations/20200607000511_add_forced_filter.exs new file mode 100644 index 00000000..f79ab687 --- /dev/null +++ b/priv/repo/migrations/20200607000511_add_forced_filter.exs @@ -0,0 +1,9 @@ +defmodule Philomena.Repo.Migrations.AddForcedFilter do + use Ecto.Migration + + def change do + alter table(:users) do + add :forced_filter_id, references(:filters), on_delete: :restrict + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 134c4952..ac6c14af 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version 12.2 (Debian 12.2-2.pgdg100+1) --- Dumped by pg_dump version 12.2 (Debian 12.2-2.pgdg90+1) +-- Dumped by pg_dump version 12.3 (Debian 12.3-1.pgdg90+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -1976,7 +1976,8 @@ CREATE TABLE public.users ( consumed_timestep integer, otp_required_for_login boolean, otp_backup_codes character varying[], - last_renamed_at timestamp without time zone DEFAULT '1970-01-01 00:00:00'::timestamp without time zone NOT NULL + last_renamed_at timestamp without time zone DEFAULT '1970-01-01 00:00:00'::timestamp without time zone NOT NULL, + forced_filter_id bigint ); @@ -4709,9 +4710,17 @@ ALTER TABLE ONLY public.image_sources ADD CONSTRAINT image_sources_image_id_fkey FOREIGN KEY (image_id) REFERENCES public.images(id); +-- +-- Name: users users_forced_filter_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_forced_filter_id_fkey FOREIGN KEY (forced_filter_id) REFERENCES public.filters(id); + + -- -- PostgreSQL database dump complete -- -INSERT INTO public."schema_migrations" (version) VALUES (20200503002523); +INSERT INTO public."schema_migrations" (version) VALUES (20200503002523), (20200607000511);