From ca4831f780c01d2bdb8abcb72adacaae10772ef0 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 23 Jul 2019 13:49:22 +0200 Subject: [PATCH] Introduce backend for reports Signed-off-by: Thomas Citharel --- config/config.exs | 10 +- lib/mobilizon/actors/actor.ex | 5 + lib/mobilizon/admin.ex | 48 +++ lib/mobilizon/admin/action_log.ex | 27 ++ lib/mobilizon/common-config.ex | 7 +- lib/mobilizon/email/admin.ex | 38 ++ lib/mobilizon/email/user.ex | 10 +- lib/mobilizon/events/events.ex | 13 + lib/mobilizon/reports.ex | 236 +++++++++++ lib/mobilizon/reports/note.ex | 27 ++ lib/mobilizon/reports/report.ex | 59 +++ lib/mobilizon/users/users.ex | 30 ++ lib/mobilizon_web/api/events.ex | 2 +- lib/mobilizon_web/api/reports.ex | 128 ++++++ lib/mobilizon_web/api/utils.ex | 12 + lib/mobilizon_web/resolvers/admin.ex | 72 ++++ lib/mobilizon_web/resolvers/report.ex | 118 ++++++ lib/mobilizon_web/schema.ex | 5 + lib/mobilizon_web/schema/admin.ex | 41 ++ lib/mobilizon_web/schema/report.ex | 86 ++++ .../templates/email/report.html.eex | 15 + .../templates/email/report.text.eex | 19 + lib/service/activity_pub/activity_pub.ex | 51 ++- lib/service/activity_pub/converters/flag.ex | 85 ++++ lib/service/activity_pub/transmogrifier.ex | 16 + lib/service/activity_pub/utils.ex | 62 ++- lib/service/admin/action_log_service.ex | 30 ++ .../20190712125833_create_reports.exs | 33 ++ .../20190712132438_create_report_notes.exs | 13 + ...0190718122556_create_admin_action_logs.exs | 15 + .../activity_pub/transmogrifier_test.exs | 23 ++ .../service/admin/action_log_service_test.exs | 43 ++ test/mobilizon_web/api/report_test.exs | 217 +++++++++++ .../resolvers/admin_resolver_test.exs | 82 ++++ .../resolvers/report_resolver_test.exs | 366 ++++++++++++++++++ test/support/factory.ex | 20 + 36 files changed, 2028 insertions(+), 36 deletions(-) create mode 100644 lib/mobilizon/admin.ex create mode 100644 lib/mobilizon/admin/action_log.ex create mode 100644 lib/mobilizon/email/admin.ex create mode 100644 lib/mobilizon/reports.ex create mode 100644 lib/mobilizon/reports/note.ex create mode 100644 lib/mobilizon/reports/report.ex create mode 100644 lib/mobilizon_web/api/reports.ex create mode 100644 lib/mobilizon_web/resolvers/admin.ex create mode 100644 lib/mobilizon_web/resolvers/report.ex create mode 100644 lib/mobilizon_web/schema/admin.ex create mode 100644 lib/mobilizon_web/schema/report.ex create mode 100644 lib/mobilizon_web/templates/email/report.html.eex create mode 100644 lib/mobilizon_web/templates/email/report.text.eex create mode 100644 lib/service/activity_pub/converters/flag.ex create mode 100644 lib/service/admin/action_log_service.ex create mode 100644 priv/repo/migrations/20190712125833_create_reports.exs create mode 100644 priv/repo/migrations/20190712132438_create_report_notes.exs create mode 100644 priv/repo/migrations/20190718122556_create_admin_action_logs.exs create mode 100644 test/mobilizon/service/admin/action_log_service_test.exs create mode 100644 test/mobilizon_web/api/report_test.exs create mode 100644 test/mobilizon_web/resolvers/admin_resolver_test.exs create mode 100644 test/mobilizon_web/resolvers/report_resolver_test.exs diff --git a/config/config.exs b/config/config.exs index 391bee315..9982acc95 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,12 +13,15 @@ config :mobilizon, :instance, name: System.get_env("MOBILIZON_INSTANCE_NAME") || "Localhost", description: System.get_env("MOBILIZON_INSTANCE_DESCRIPTION") || "This is a Mobilizon instance", version: "1.0.0-dev", + hostname: System.get_env("MOBILIZON_INSTANCE_HOST") || "localhost", registrations_open: System.get_env("MOBILIZON_INSTANCE_REGISTRATIONS_OPEN") || false, repository: Mix.Project.config()[:source_url], remote_limit: 100_000, upload_limit: 16_000_000, avatar_upload_limit: 2_000_000, - banner_upload_limit: 4_000_000 + banner_upload_limit: 4_000_000, + email_from: "noreply@localhost", + email_reply_to: "noreply@localhost" config :mime, :types, %{ "application/activity+json" => ["activity-json"], @@ -30,10 +33,7 @@ config :mobilizon, MobilizonWeb.Endpoint, url: [host: "localhost"], secret_key_base: "1yOazsoE0Wqu4kXk3uC5gu3jDbShOimTCzyFL3OjCdBmOXMyHX87Qmf3+Tu9s0iM", render_errors: [view: MobilizonWeb.ErrorView, accepts: ~w(html json)], - pubsub: [name: Mobilizon.PubSub, adapter: Phoenix.PubSub.PG2], - instance: "localhost", - email_from: "noreply@localhost", - email_to: "noreply@localhost" + pubsub: [name: Mobilizon.PubSub, adapter: Phoenix.PubSub.PG2] # Upload configuration config :mobilizon, MobilizonWeb.Upload, diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index f48b42517..6cd79ca7e 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -35,6 +35,8 @@ defmodule Mobilizon.Actors.Actor do alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Media.File + alias Mobilizon.Reports.{Report, Note} + alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint @@ -72,6 +74,9 @@ defmodule Mobilizon.Actors.Actor do has_many(:feed_tokens, FeedToken, foreign_key: :actor_id) embeds_one(:avatar, File, on_replace: :update) embeds_one(:banner, File, on_replace: :update) + has_many(:created_reports, Report, foreign_key: :reporter_id) + has_many(:subject_reports, Report, foreign_key: :reported_id) + has_many(:report_notes, Note, foreign_key: :moderator_id) timestamps() end diff --git a/lib/mobilizon/admin.ex b/lib/mobilizon/admin.ex new file mode 100644 index 000000000..fba5ee050 --- /dev/null +++ b/lib/mobilizon/admin.ex @@ -0,0 +1,48 @@ +defmodule Mobilizon.Admin do + @moduledoc """ + The Admin context. + """ + + import Ecto.Query, warn: false + alias Mobilizon.Repo + import Mobilizon.Ecto + + alias Mobilizon.Admin.ActionLog + + @doc """ + Returns the list of action_logs. + + ## Examples + + iex> list_action_logs() + [%ActionLog{}, ...] + + """ + @spec list_action_logs(integer(), integer()) :: list(ActionLog.t()) + def list_action_logs(page \\ nil, limit \\ nil) do + from( + r in ActionLog, + preload: [:actor] + ) + |> paginate(page, limit) + |> Repo.all() + end + + @doc """ + Creates a action_log. + + ## Examples + + iex> create_action_log(%{field: value}) + {:ok, %ActionLog{}} + + iex> create_action_log(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_action_log(attrs \\ %{}) do + %ActionLog{} + |> ActionLog.changeset(attrs) + |> Repo.insert() + end +end diff --git a/lib/mobilizon/admin/action_log.ex b/lib/mobilizon/admin/action_log.ex new file mode 100644 index 000000000..2b3f80f7e --- /dev/null +++ b/lib/mobilizon/admin/action_log.ex @@ -0,0 +1,27 @@ +defmodule Mobilizon.Admin.ActionLog do + @moduledoc """ + ActionLog entity schema + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor + + @required_attrs [:action, :target_type, :target_id, :changes, :actor_id] + + schema "admin_action_logs" do + field(:action, :string) + field(:target_type, :string) + field(:target_id, :integer) + field(:changes, :map) + belongs_to(:actor, Actor) + + timestamps() + end + + @doc false + def changeset(action_log, attrs) do + action_log + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs -- [:changes]) + end +end diff --git a/lib/mobilizon/common-config.ex b/lib/mobilizon/common-config.ex index 2fa021058..091857777 100644 --- a/lib/mobilizon/common-config.ex +++ b/lib/mobilizon/common-config.ex @@ -19,7 +19,12 @@ defmodule Mobilizon.CommonConfig do |> get_in([:description]) end - defp instance_config(), do: Application.get_env(:mobilizon, :instance) + def instance_hostname() do + instance_config() + |> get_in([:hostname]) + end + + def instance_config(), do: Application.get_env(:mobilizon, :instance) defp to_bool(v), do: v == true or v == "true" or v == "True" diff --git a/lib/mobilizon/email/admin.ex b/lib/mobilizon/email/admin.ex new file mode 100644 index 000000000..2157257a6 --- /dev/null +++ b/lib/mobilizon/email/admin.ex @@ -0,0 +1,38 @@ +defmodule Mobilizon.Email.Admin do + @moduledoc """ + Handles emails sent to admins + """ + alias Mobilizon.Users.User + + import Bamboo.Email + import Bamboo.Phoenix + use Bamboo.Phoenix, view: Mobilizon.EmailView + import MobilizonWeb.Gettext + alias Mobilizon.Reports.Report + + def report(%User{email: email} = _user, %Report{} = report, locale \\ "en") do + Gettext.put_locale(locale) + instance_url = get_config(:hostname) + + base_email() + |> to(email) + |> subject(gettext("Mobilizon: New report on instance %{instance}", instance: instance_url)) + |> put_header("Reply-To", get_config(:email_reply_to)) + |> assign(:report, report) + |> assign(:instance, instance_url) + |> render(:report) + end + + defp base_email do + # Here you can set a default from, default headers, etc. + new_email() + |> from(get_config(:email_from)) + |> put_html_layout({Mobilizon.EmailView, "email.html"}) + |> put_text_layout({Mobilizon.EmailView, "email.text"}) + end + + @spec get_config(atom()) :: any() + defp get_config(key) do + Mobilizon.CommonConfig.instance_config() |> Keyword.get(key) + end +end diff --git a/lib/mobilizon/email/user.ex b/lib/mobilizon/email/user.ex index 72268b16e..53bdb4a1d 100644 --- a/lib/mobilizon/email/user.ex +++ b/lib/mobilizon/email/user.ex @@ -18,7 +18,7 @@ defmodule Mobilizon.Email.User do |> subject( gettext("Mobilizon: Confirmation instructions for %{instance}", instance: instance_url) ) - |> put_header("Reply-To", get_config(:reply_to)) + |> put_header("Reply-To", get_config(:email_reply_to)) |> assign(:token, user.confirmation_token) |> assign(:instance, instance_url) |> render(:registration_confirmation) @@ -26,7 +26,7 @@ defmodule Mobilizon.Email.User do def reset_password_email(%User{} = user, locale \\ "en") do Gettext.put_locale(locale) - instance_url = get_config(:instance) + instance_url = get_config(:hostname) base_email() |> to(user.email) @@ -36,7 +36,7 @@ defmodule Mobilizon.Email.User do instance: instance_url ) ) - |> put_header("Reply-To", get_config(:reply_to)) + |> put_header("Reply-To", get_config(:email_reply_to)) |> assign(:token, user.reset_password_token) |> assign(:instance, instance_url) |> render(:password_reset) @@ -45,13 +45,13 @@ defmodule Mobilizon.Email.User do defp base_email do # Here you can set a default from, default headers, etc. new_email() - |> from(Application.get_env(:mobilizon, MobilizonWeb.Endpoint)[:email_from]) + |> from(get_config(:email_from)) |> put_html_layout({Mobilizon.EmailView, "email.html"}) |> put_text_layout({Mobilizon.EmailView, "email.text"}) end @spec get_config(atom()) :: any() defp get_config(key) do - _config = Application.get_env(:mobilizon, MobilizonWeb.Endpoint) |> Keyword.get(key) + Mobilizon.CommonConfig.instance_config() |> Keyword.get(key) end end diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 17cb8f8bd..38897e73d 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -1160,6 +1160,19 @@ defmodule Mobilizon.Events do end end + @doc """ + Get all comments by an actor and a list of ids + """ + def get_all_comments_by_actor_and_ids(actor_id, comment_ids \\ []) + def get_all_comments_by_actor_and_ids(_actor_id, []), do: [] + + def get_all_comments_by_actor_and_ids(actor_id, comment_ids) do + Comment + |> where([c], c.id in ^comment_ids) + |> where([c], c.actor_id == ^actor_id) + |> Repo.all() + end + @doc """ Creates a comment. diff --git a/lib/mobilizon/reports.ex b/lib/mobilizon/reports.ex new file mode 100644 index 000000000..1bfaac7ac --- /dev/null +++ b/lib/mobilizon/reports.ex @@ -0,0 +1,236 @@ +defmodule Mobilizon.Reports do + @moduledoc """ + The Reports context. + """ + + import Ecto.Query, warn: false + alias Mobilizon.Repo + import Mobilizon.Ecto + + alias Mobilizon.Reports.Report + alias Mobilizon.Reports.Note + + @doc false + def data() do + Dataloader.Ecto.new(Mobilizon.Repo, query: &query/2) + end + + @doc false + def query(queryable, _params) do + queryable + end + + @doc """ + Returns the list of reports. + + ## Examples + + iex> list_reports() + [%Report{}, ...] + + """ + @spec list_reports(integer(), integer(), atom(), atom()) :: list(Report.t()) + def list_reports(page \\ nil, limit \\ nil, sort \\ :updated_at, direction \\ :asc) do + from( + r in Report, + preload: [:reported, :reporter, :manager, :event, :comments, :notes] + ) + |> paginate(page, limit) + |> sort(sort, direction) + |> Repo.all() + end + + @doc """ + Gets a single report. + + Raises `Ecto.NoResultsError` if the Report does not exist. + + ## Examples + + iex> get_report!(123) + %Report{} + + iex> get_report!(456) + ** (Ecto.NoResultsError) + + """ + def get_report!(id) do + with %Report{} = report <- Repo.get!(Report, id) do + Repo.preload(report, [:reported, :reporter, :manager, :event, :comments, :notes]) + end + end + + @doc """ + Gets a single report. + + Returns `nil` if the Report does not exist. + + ## Examples + + iex> get_report(123) + %Report{} + + iex> get_report(456) + nil + + """ + def get_report(id) do + with %Report{} = report <- Repo.get(Report, id) do + Repo.preload(report, [:reported, :reporter, :manager, :event, :comments, :notes]) + end + end + + @doc """ + Get a report by it's URL + """ + @spec get_report_by_url(String.t()) :: Report.t() | nil + def get_report_by_url(url) do + from( + r in Report, + where: r.uri == ^url + ) + |> Repo.one() + end + + @doc """ + Creates a report. + + ## Examples + + iex> create_report(%{field: value}) + {:ok, %Report{}} + + iex> create_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_report(attrs \\ %{}) do + with {:ok, %Report{} = report} <- + %Report{} + |> Report.creation_changeset(attrs) + |> Repo.insert() do + {:ok, Repo.preload(report, [:event, :reported, :reporter, :comments])} + end + end + + @doc """ + Updates a report. + + ## Examples + + iex> update_report(report, %{field: new_value}) + {:ok, %Report{}} + + iex> update_report(report, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_report(%Report{} = report, attrs) do + report + |> Report.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a Report. + + ## Examples + + iex> delete_report(report) + {:ok, %Report{}} + + iex> delete_report(report) + {:error, %Ecto.Changeset{}} + + """ + def delete_report(%Report{} = report) do + Repo.delete(report) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking report changes. + + ## Examples + + iex> change_report(report) + %Ecto.Changeset{source: %Report{}} + + """ + def change_report(%Report{} = report) do + Report.changeset(report, %{}) + end + + @doc """ + Returns the list of notes for a report. + + ## Examples + + iex> list_notes_for_report(%Report{id: 1}) + [%Note{}, ...] + + """ + @spec list_notes_for_report(Report.t()) :: list(Report.t()) + def list_notes_for_report(%Report{id: report_id}) do + from( + n in Note, + where: n.report_id == ^report_id, + preload: [:report, :moderator] + ) + |> Repo.all() + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + def get_note(id), do: Repo.get(Note, id) + + @doc """ + Creates a note report. + + ## Examples + + iex> create_report_note(%{field: value}) + {:ok, %Note{}} + + iex> create_report_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_report_note(attrs \\ %{}) do + with {:ok, %Note{} = note} <- + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() do + {:ok, Repo.preload(note, [:report, :moderator])} + end + end + + @doc """ + Deletes a note report. + + ## Examples + + iex> delete_report_note(note) + {:ok, %Note{}} + + iex> delete_report_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_report_note(%Note{} = note) do + Repo.delete(note) + end +end diff --git a/lib/mobilizon/reports/note.ex b/lib/mobilizon/reports/note.ex new file mode 100644 index 000000000..a7d8ad30e --- /dev/null +++ b/lib/mobilizon/reports/note.ex @@ -0,0 +1,27 @@ +defmodule Mobilizon.Reports.Note do + @moduledoc """ + Report Note entity + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor + alias Mobilizon.Reports.Report + + @attrs [:content, :moderator_id, :report_id] + + @derive {Jason.Encoder, only: [:content]} + schema "report_notes" do + field(:content, :string) + belongs_to(:moderator, Actor) + belongs_to(:report, Report) + + timestamps() + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, @attrs) + |> validate_required(@attrs) + end +end diff --git a/lib/mobilizon/reports/report.ex b/lib/mobilizon/reports/report.ex new file mode 100644 index 000000000..11d55e824 --- /dev/null +++ b/lib/mobilizon/reports/report.ex @@ -0,0 +1,59 @@ +import EctoEnum + +defenum(Mobilizon.Reports.ReportStateEnum, :report_state, [ + :open, + :closed, + :resolved +]) + +defmodule Mobilizon.Reports.Report do + @moduledoc """ + Report entity + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Events.Comment + alias Mobilizon.Events.Event + alias Mobilizon.Actors.Actor + alias Mobilizon.Reports.Note + + @derive {Jason.Encoder, only: [:status, :uri]} + schema "reports" do + field(:content, :string) + field(:status, Mobilizon.Reports.ReportStateEnum, default: :open) + field(:uri, :string) + + # The reported actor + belongs_to(:reported, Actor) + + # The actor who reported + belongs_to(:reporter, Actor) + + # The actor who last acted on this report + belongs_to(:manager, Actor) + + # The eventual Event inside the report + belongs_to(:event, Event) + + # The eventual Comments inside the report + many_to_many(:comments, Comment, join_through: "reports_comments", on_replace: :delete) + + # The notes associated to the report + has_many(:notes, Note, foreign_key: :report_id) + + timestamps() + end + + @doc false + def changeset(report, attrs) do + report + |> cast(attrs, [:content, :status, :uri, :reported_id, :reporter_id, :manager_id, :event_id]) + |> validate_required([:content, :uri, :reported_id, :reporter_id]) + end + + def creation_changeset(report, attrs) do + report + |> changeset(attrs) + |> put_assoc(:comments, attrs["comments"]) + end +end diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 1628cc670..4f603cb79 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -258,6 +258,36 @@ defmodule Mobilizon.Users do ) end + @doc """ + Returns the list of administrators. + + ## Examples + + iex> list_admins() + [%Mobilizon.Users.User{role: :administrator}] + + """ + def list_admins() do + User + |> where([u], u.role == ^:administrator) + |> Repo.all() + end + + @doc """ + Returns the list of moderators. + + ## Examples + + iex> list_moderators() + [%Mobilizon.Users.User{role: :moderator}, %Mobilizon.Users.User{role: :administrator}] + + """ + def list_moderators() do + User + |> where([u], u.role in ^[:administrator, :moderator]) + |> Repo.all() + end + def count_users() do Repo.one( from( diff --git a/lib/mobilizon_web/api/events.ex b/lib/mobilizon_web/api/events.ex index 7adb7ac01..e5873800a 100644 --- a/lib/mobilizon_web/api/events.ex +++ b/lib/mobilizon_web/api/events.ex @@ -26,7 +26,7 @@ defmodule MobilizonWeb.API.Events do Actors.get_local_actor_with_everything(organizer_actor_id), title <- String.trim(title), mentions <- Formatter.parse_mentions(description), - visibility <- Map.get(args, :visibility, "public"), + visibility <- Map.get(args, :visibility, :public), {to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, Atom.to_string(visibility)), tags <- Formatter.parse_tags(description), picture <- Map.get(args, :picture, nil), diff --git a/lib/mobilizon_web/api/reports.ex b/lib/mobilizon_web/api/reports.ex new file mode 100644 index 000000000..3010c6ecb --- /dev/null +++ b/lib/mobilizon_web/api/reports.ex @@ -0,0 +1,128 @@ +defmodule MobilizonWeb.API.Reports do + @moduledoc """ + API for Reports + """ + + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Events + alias Mobilizon.Activity + alias Mobilizon.Reports, as: ReportsAction + alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Service.ActivityPub + alias Mobilizon.Users + alias Mobilizon.Users.User + import MobilizonWeb.API.Utils + import Mobilizon.Service.Admin.ActionLogService + + @doc """ + Create a report/flag on an actor, and optionally on an event or on comments. + """ + def report( + %{ + reporter_actor_id: reporter_actor_id, + reported_actor_id: reported_actor_id, + event_id: event_id, + comments_ids: comments_ids, + report_content: report_content + } = args + ) do + with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <- + {:reporter, Actors.get_actor!(reporter_actor_id)}, + {:reported, %Actor{url: reported_actor_url} = reported_actor} <- + {:reported, Actors.get_actor!(reported_actor_id)}, + {:ok, content} <- make_report_content_html(report_content), + {:ok, event} <- + if(event_id, do: Events.get_event(event_id), else: {:ok, nil}), + {:get_report_comments, comments_urls} <- + get_report_comments(reported_actor, comments_ids), + {:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} <- + {:make_activity, + ActivityPub.flag(%{ + reporter_url: reporter_url, + reported_actor_url: reported_actor_url, + event_url: (!is_nil(event) && event.url) || nil, + comments_url: comments_urls, + content: content, + forward: args[:forward] || false, + local: args[:local] || args[:forward] || false + })} do + {:ok, activity, report} + else + {:error, err} -> {:error, err} + {:actor_id, %{}} -> {:error, "Valid `actor_id` required"} + {:reporter, nil} -> {:error, "Reporter Actor not found"} + {:reported, nil} -> {:error, "Reported Actor not found"} + end + end + + @doc """ + Update the state of a report + """ + def update_report_status(%Actor{} = actor, %Report{} = report, state) do + with {:valid_state, true} <- + {:valid_state, Mobilizon.Reports.ReportStateEnum.valid_value?(state)}, + {:ok, report} <- ReportsAction.update_report(report, %{"status" => state}), + {:ok, _} <- log_action(actor, "update", report) do + {:ok, report} + else + {:valid_state, false} -> {:error, "Unsupported state"} + end + end + + defp get_report_comments(%Actor{id: actor_id}, comment_ids) do + {:get_report_comments, + Events.get_all_comments_by_actor_and_ids(actor_id, comment_ids) |> Enum.map(& &1.url)} + end + + defp get_report_comments(_, _), do: {:get_report_comments, nil} + + @doc """ + Create a note on a report + """ + @spec create_report_note(Report.t(), Actor.t(), String.t()) :: {:ok, Note.t()} + def create_report_note( + %Report{id: report_id}, + %Actor{id: moderator_id, user_id: user_id} = moderator, + content + ) do + with %User{role: role} <- Users.get_user!(user_id), + {:role, true} <- {:role, role in [:administrator, :moderator]}, + {:ok, %Note{} = note} <- + Mobilizon.Reports.create_report_note(%{ + "report_id" => report_id, + "moderator_id" => moderator_id, + "content" => content + }), + {:ok, _} <- log_action(moderator, "create", note) do + {:ok, note} + else + {:role, false} -> + {:error, "You need to be a moderator or an administrator to create a note on a report"} + end + end + + @doc """ + Delete a report note + """ + @spec delete_report_note(Note.t(), Actor.t()) :: {:ok, Note.t()} + def delete_report_note( + %Note{moderator_id: note_moderator_id} = note, + %Actor{id: moderator_id, user_id: user_id} = moderator + ) do + with {:same_actor, true} <- {:same_actor, note_moderator_id == moderator_id}, + %User{role: role} <- Users.get_user!(user_id), + {:role, true} <- {:role, role in [:administrator, :moderator]}, + {:ok, %Note{} = note} <- + Mobilizon.Reports.delete_report_note(note), + {:ok, _} <- log_action(moderator, "delete", note) do + {:ok, note} + else + {:role, false} -> + {:error, "You need to be a moderator or an administrator to create a note on a report"} + + {:same_actor, false} -> + {:error, "You can only remove your own notes"} + end + end +end diff --git a/lib/mobilizon_web/api/utils.ex b/lib/mobilizon_web/api/utils.ex index 8305a0a93..c06e641a4 100644 --- a/lib/mobilizon_web/api/utils.ex +++ b/lib/mobilizon_web/api/utils.ex @@ -120,4 +120,16 @@ defmodule MobilizonWeb.API.Utils do # |> Formatter.add_hashtag_links(tags) # |> Formatter.finalize() # end + + def make_report_content_html(nil), do: {:ok, {nil, [], []}} + + def make_report_content_html(comment) do + max_size = Mobilizon.CommonConfig.get([:instance, :max_report_comment_size], 1000) + + if String.length(comment) <= max_size do + {:ok, Formatter.html_escape(comment, "text/plain")} + else + {:error, "Comment must be up to #{max_size} characters"} + end + end end diff --git a/lib/mobilizon_web/resolvers/admin.ex b/lib/mobilizon_web/resolvers/admin.ex new file mode 100644 index 000000000..4f9972428 --- /dev/null +++ b/lib/mobilizon_web/resolvers/admin.ex @@ -0,0 +1,72 @@ +defmodule MobilizonWeb.Resolvers.Admin do + @moduledoc """ + Handles the report-related GraphQL calls + """ + alias Mobilizon.Users.User + import Mobilizon.Users.Guards + alias Mobilizon.Admin.ActionLog + alias Mobilizon.Reports.{Report, Note} + + def list_action_logs(_parent, %{page: page, limit: limit}, %{ + context: %{current_user: %User{role: role}} + }) + when is_moderator(role) do + with action_logs <- Mobilizon.Admin.list_action_logs(page, limit) do + action_logs = + Enum.map(action_logs, fn %ActionLog{ + target_type: target_type, + action: action, + actor: actor, + id: id + } = action_log -> + transform_action_log(target_type, action, action_log) + |> Map.merge(%{ + actor: actor, + id: id + }) + end) + + {:ok, action_logs} + end + end + + def list_action_logs(_parent, _args, _resolution) do + {:error, "You need to be logged-in and a moderator to list action logs"} + end + + defp transform_action_log( + "Elixir.Mobilizon.Reports.Report", + "update", + %ActionLog{} = action_log + ) do + with %Report{status: status} = report <- Mobilizon.Reports.get_report(action_log.target_id) do + %{ + action: "report_update_" <> to_string(status), + object: report + } + end + end + + defp transform_action_log("Elixir.Mobilizon.Reports.Note", "create", %ActionLog{ + changes: changes + }) do + %{ + action: "note_creation", + object: convert_changes_to_struct(Note, changes) + } + end + + defp transform_action_log("Elixir.Mobilizon.Reports.Note", "delete", %ActionLog{ + changes: changes + }) do + %{ + action: "note_deletion", + object: convert_changes_to_struct(Note, changes) + } + end + + # Changes are stored as %{"key" => "value"} so we need to convert them back as struct + defp convert_changes_to_struct(struct, changes) do + struct(struct, for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val})) + end +end diff --git a/lib/mobilizon_web/resolvers/report.ex b/lib/mobilizon_web/resolvers/report.ex new file mode 100644 index 000000000..dd3655050 --- /dev/null +++ b/lib/mobilizon_web/resolvers/report.ex @@ -0,0 +1,118 @@ +defmodule MobilizonWeb.Resolvers.Report do + @moduledoc """ + Handles the report-related GraphQL calls + """ + alias Mobilizon.Reports + alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Actors.Actor + alias Mobilizon.Actors + alias Mobilizon.Users.User + alias MobilizonWeb.API.Reports, as: ReportsAPI + import Mobilizon.Users.Guards + + def list_reports(_parent, %{page: page, limit: limit}, %{ + context: %{current_user: %User{role: role}} + }) + when is_moderator(role) do + {:ok, Mobilizon.Reports.list_reports(page, limit)} + end + + def list_reports(_parent, _args, _resolution) do + {:error, "You need to be logged-in and a moderator to list reports"} + end + + def get_report(_parent, %{id: id}, %{ + context: %{current_user: %User{role: role}} + }) + when is_moderator(role) do + {:ok, Mobilizon.Reports.get_report(id)} + end + + def get_report(_parent, _args, _resolution) do + {:error, "You need to be logged-in and a moderator to view a report"} + end + + @doc """ + Create a report + """ + def create_report( + _parent, + %{reporter_actor_id: reporter_actor_id} = args, + %{context: %{current_user: user}} = _resolution + ) do + with {:is_owned, true, _} <- User.owns_actor(user, reporter_actor_id), + {:ok, _, %Report{} = report} <- ReportsAPI.report(args) do + {:ok, report} + else + {:is_owned, false} -> + {:error, "Reporter actor id is not owned by authenticated user"} + + _err -> + {:error, "Error while saving report"} + end + end + + def create_report(_parent, _args, _resolution) do + {:error, "You need to be logged-in to create reports"} + end + + @doc """ + Update a report's status + """ + def update_report( + _parent, + %{report_id: report_id, moderator_id: moderator_id, status: status}, + %{ + context: %{current_user: %User{role: role} = user} + } + ) + when is_moderator(role) do + with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), + %Actor{} = actor <- Actors.get_actor!(moderator_id), + %Report{} = report <- Mobilizon.Reports.get_report(report_id), + {:ok, %Report{} = report} <- + MobilizonWeb.API.Reports.update_report_status(actor, report, status) do + {:ok, report} + else + {:is_owned, false} -> + {:error, "Actor id is not owned by authenticated user"} + + _err -> + {:error, "Error while updating report"} + end + end + + def update_report(_parent, _args, _resolution) do + {:error, "You need to be logged-in and a moderator to update a report"} + end + + def create_report_note( + _parent, + %{report_id: report_id, moderator_id: moderator_id, content: content}, + %{ + context: %{current_user: %User{role: role} = user} + } + ) + when is_moderator(role) do + with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), + %Report{} = report <- Reports.get_report(report_id), + %Actor{} = moderator <- Actors.get_local_actor_with_everything(moderator_id), + {:ok, %Note{} = note} <- + MobilizonWeb.API.Reports.create_report_note(report, moderator, content) do + {:ok, note} + end + end + + def delete_report_note(_parent, %{note_id: note_id, moderator_id: moderator_id}, %{ + context: %{current_user: %User{role: role} = user} + }) + when is_moderator(role) do + with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), + %Note{} = note <- Reports.get_note(note_id), + %Actor{} = moderator <- Actors.get_local_actor_with_everything(moderator_id), + {:ok, %Note{} = note} <- + MobilizonWeb.API.Reports.delete_report_note(note, moderator) do + {:ok, %{id: note.id}} + end + end +end diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index e1cc49e16..38d223007 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -21,6 +21,8 @@ defmodule MobilizonWeb.Schema do import_types(MobilizonWeb.Schema.CommentType) import_types(MobilizonWeb.Schema.SearchType) import_types(MobilizonWeb.Schema.ConfigType) + import_types(MobilizonWeb.Schema.ReportType) + import_types(MobilizonWeb.Schema.AdminType) @desc "A struct containing the id of the deleted object" object :deleted_object do @@ -109,6 +111,8 @@ defmodule MobilizonWeb.Schema do import_fields(:address_queries) import_fields(:config_queries) import_fields(:picture_queries) + import_fields(:report_queries) + import_fields(:admin_queries) end @desc """ @@ -124,5 +128,6 @@ defmodule MobilizonWeb.Schema do import_fields(:member_mutations) import_fields(:feed_token_mutations) import_fields(:picture_mutations) + import_fields(:report_mutations) end end diff --git a/lib/mobilizon_web/schema/admin.ex b/lib/mobilizon_web/schema/admin.ex new file mode 100644 index 000000000..fa73ec0a9 --- /dev/null +++ b/lib/mobilizon_web/schema/admin.ex @@ -0,0 +1,41 @@ +defmodule MobilizonWeb.Schema.AdminType do + @moduledoc """ + Schema representation for ActionLog + """ + use Absinthe.Schema.Notation + alias MobilizonWeb.Resolvers.Admin + alias Mobilizon.Reports.{Report, Note} + + @desc "An action log" + object :action_log do + field(:id, :id, description: "Internal ID for this comment") + field(:actor, :actor, description: "The actor that acted") + field(:object, :action_log_object, description: "The object that was acted upon") + field(:action, :string, description: "The action that was done") + end + + @desc "The objects that can be in an action log" + interface :action_log_object do + field(:id, :id, description: "Internal ID for this object") + + resolve_type(fn + %Report{}, _ -> + :report + + %Note{}, _ -> + :report_note + + _, _ -> + nil + end) + end + + object :admin_queries do + @desc "Get the list of action logs" + field :action_logs, type: list_of(:action_log) do + arg(:page, :integer, default_value: 1) + arg(:limit, :integer, default_value: 10) + resolve(&Admin.list_action_logs/3) + end + end +end diff --git a/lib/mobilizon_web/schema/report.ex b/lib/mobilizon_web/schema/report.ex new file mode 100644 index 000000000..ea3a5cdaf --- /dev/null +++ b/lib/mobilizon_web/schema/report.ex @@ -0,0 +1,86 @@ +defmodule MobilizonWeb.Schema.ReportType do + @moduledoc """ + Schema representation for User + """ + use Absinthe.Schema.Notation + + alias MobilizonWeb.Resolvers.Report + + @desc "A report object" + object :report do + interfaces([:action_log_object]) + field(:id, :id, description: "The internal ID of the report") + field(:content, :string, description: "The comment the reporter added about this report") + field(:status, :report_status, description: "Whether the report is still active") + field(:uri, :string, description: "The URI of the report") + field(:reported, :actor, description: "The actor that is being reported") + field(:reporter, :actor, description: "The actor that created the report") + field(:event, :event, description: "The event that is being reported") + field(:comments, list_of(:comment), description: "The comments that are reported") + end + + @desc "A report note object" + object :report_note do + interfaces([:action_log_object]) + field(:id, :id, description: "The internal ID of the report note") + field(:content, :string, description: "The content of the note") + field(:moderator, :actor, description: "The moderator who added the note") + field(:report, :report, description: "The report on which this note is added") + end + + @desc "The list of possible statuses for a report object" + enum :report_status do + value(:open, description: "The report has been opened") + value(:closed, description: "The report has been closed") + value(:resolved, description: "The report has been marked as resolved") + end + + object :report_queries do + @desc "Get all reports" + field :reports, list_of(:report) do + arg(:page, :integer, default_value: 1) + arg(:limit, :integer, default_value: 10) + resolve(&Report.list_reports/3) + end + + @desc "Get a report by id" + field :report, :report do + arg(:id, non_null(:id)) + resolve(&Report.get_report/3) + end + end + + object :report_mutations do + @desc "Create a report" + field :create_report, type: :report do + arg(:report_content, :string) + arg(:reporter_actor_id, non_null(:id)) + arg(:reported_actor_id, non_null(:id)) + arg(:event_id, :id, default_value: nil) + arg(:comments_ids, list_of(:id), default_value: []) + resolve(&Report.create_report/3) + end + + @desc "Update a report" + field :update_report_status, type: :report do + arg(:report_id, non_null(:id)) + arg(:moderator_id, non_null(:id)) + arg(:status, non_null(:report_status)) + resolve(&Report.update_report/3) + end + + @desc "Create a note on a report" + field :create_report_note, type: :report_note do + arg(:content, :string) + arg(:moderator_id, non_null(:id)) + arg(:report_id, non_null(:id)) + resolve(&Report.create_report_note/3) + end + + field :delete_report_note, type: :deleted_object do + arg(:note_id, non_null(:id)) + arg(:moderator_id, non_null(:id)) + resolve(&Report.delete_report_note/3) + end + end +end diff --git a/lib/mobilizon_web/templates/email/report.html.eex b/lib/mobilizon_web/templates/email/report.html.eex new file mode 100644 index 000000000..3740eea67 --- /dev/null +++ b/lib/mobilizon_web/templates/email/report.html.eex @@ -0,0 +1,15 @@ +

<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %>

+ +<% if @report.event do %> +

<%= gettext "Event: %{event}", event: @report.event %>

+<% end %> + +<%= for comment <- @report.comments do %> +

<%= gettext "Comment: %{comment}", comment: comment %>

+<% end %> + +<% if @content do %> +

<%= gettext "Reason: %{content}", event: @report.content %>

+<% end %> + +

<%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %>

diff --git a/lib/mobilizon_web/templates/email/report.text.eex b/lib/mobilizon_web/templates/email/report.text.eex new file mode 100644 index 000000000..af4233ece --- /dev/null +++ b/lib/mobilizon_web/templates/email/report.text.eex @@ -0,0 +1,19 @@ +<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %> + +-- + +<% if @report.event do %> + <%= gettext "Event: %{event}", event: @report.event %> +<% end %> + +<%= for comment <- @report.comments do %> +<%= gettext "Comment: %{comment}", comment: comment %> +<% end %> + +<% if @content do %> +<%= gettext "Reason: %{content}", event: @report.content %> +<% end %> + +<%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %> + + diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index d93ab20a0..be66b630b 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -41,7 +41,7 @@ defmodule Mobilizon.Service.ActivityPub do @spec insert(map(), boolean()) :: {:ok, %Activity{}} | {:error, any()} def insert(map, local \\ true) when is_map(map) do with map <- lazy_put_activity_defaults(map), - :ok <- insert_full_object(map) do + {:ok, object} <- insert_full_object(map) do object_id = if is_map(map["object"]), do: map["object"]["id"], else: map["id"] map = if local, do: Map.put(map, "id", "#{object_id}/activity"), else: map @@ -55,7 +55,7 @@ defmodule Mobilizon.Service.ActivityPub do # Notification.create_notifications(activity) # stream_out(activity) - {:ok, activity} + {:ok, activity, object} else %Activity{} = activity -> {:ok, activity} error -> {:error, error} @@ -130,7 +130,7 @@ defmodule Mobilizon.Service.ActivityPub do additional ), :ok <- Logger.debug(inspect(create_data)), - {:ok, activity} <- insert(create_data, local), + {:ok, activity, _object} <- insert(create_data, local), :ok <- maybe_federate(activity) do # {:ok, actor} <- Actors.increase_event_count(actor) do {:ok, activity} @@ -147,7 +147,7 @@ defmodule Mobilizon.Service.ActivityPub do local = !(params[:local] == false) with data <- %{"to" => to, "type" => "Accept", "actor" => actor, "object" => object}, - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -164,7 +164,7 @@ defmodule Mobilizon.Service.ActivityPub do "actor" => actor, "object" => object }, - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -179,7 +179,7 @@ defmodule Mobilizon.Service.ActivityPub do # ) do # with nil <- get_existing_like(url, object), # like_data <- make_like_data(user, object, activity_id), - # {:ok, activity} <- insert(like_data, local), + # {:ok, activity, _object} <- insert(like_data, local), # {:ok, object} <- add_like_to_object(activity, object), # :ok <- maybe_federate(activity) do # {:ok, activity, object} @@ -197,7 +197,7 @@ defmodule Mobilizon.Service.ActivityPub do # ) do # with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object), # unlike_data <- make_unlike_data(actor, like_activity, activity_id), - # {:ok, unlike_activity} <- insert(unlike_data, local), + # {:ok, unlike_activity, _object} <- insert(unlike_data, local), # {:ok, _activity} <- Repo.delete(like_activity), # {:ok, object} <- remove_like_from_object(like_activity, object), # :ok <- maybe_federate(unlike_activity) do @@ -215,7 +215,7 @@ defmodule Mobilizon.Service.ActivityPub do # ) do # #with true <- is_public?(object), # with announce_data <- make_announce_data(actor, object, activity_id), - # {:ok, activity} <- insert(announce_data, local), + # {:ok, activity, _object} <- insert(announce_data, local), # # {:ok, object} <- add_announce_to_object(activity, object), # :ok <- maybe_federate(activity) do # {:ok, activity, object} @@ -232,7 +232,7 @@ defmodule Mobilizon.Service.ActivityPub do # ) do # with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object), # unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id), - # {:ok, unannounce_activity} <- insert(unannounce_data, local), + # {:ok, unannounce_activity, _object} <- insert(unannounce_data, local), # :ok <- maybe_federate(unannounce_activity), # {:ok, _activity} <- Repo.delete(announce_activity), # {:ok, object} <- remove_announce_from_object(announce_activity, object) do @@ -250,7 +250,7 @@ defmodule Mobilizon.Service.ActivityPub do activity_follow_id <- activity_id || "#{MobilizonWeb.Endpoint.url()}/follow/#{follow_id}/activity", data <- make_follow_data(followed, follower, activity_follow_id), - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -267,9 +267,9 @@ defmodule Mobilizon.Service.ActivityPub do with {:ok, %Follower{id: follow_id}} <- Actor.unfollow(followed, follower), # We recreate the follow activity data <- make_follow_data(followed, follower, follow_id), - {:ok, follow_activity} <- insert(data, local), + {:ok, follow_activity, _object} <- insert(data, local), unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), - {:ok, activity} <- insert(unfollow_data, local), + {:ok, activity, _object} <- insert(unfollow_data, local), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -290,7 +290,7 @@ defmodule Mobilizon.Service.ActivityPub do } with Events.delete_event(event), - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -305,7 +305,7 @@ defmodule Mobilizon.Service.ActivityPub do } with Events.delete_comment(comment), - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -320,12 +320,33 @@ defmodule Mobilizon.Service.ActivityPub do } with Actors.delete_actor(actor), - {:ok, activity} <- insert(data, local), + {:ok, activity, _object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity} end end + def flag(params) do + # only accept false as false value + local = !(params[:local] == false) + forward = !(params[:forward] == false) + + additional = params[:additional] || %{} + + additional = + if forward do + Map.merge(additional, %{"to" => [], "cc" => [params.reported_actor_url]}) + else + Map.merge(additional, %{"to" => [], "cc" => []}) + end + + with flag_data <- make_flag_data(params, additional), + {:ok, activity, report} <- insert(flag_data, local), + :ok <- maybe_federate(activity) do + {:ok, activity, report} + end + end + @doc """ Create an actor locally by it's URL (AP ID) """ diff --git a/lib/service/activity_pub/converters/flag.ex b/lib/service/activity_pub/converters/flag.ex new file mode 100644 index 000000000..1a8fff623 --- /dev/null +++ b/lib/service/activity_pub/converters/flag.ex @@ -0,0 +1,85 @@ +defmodule Mobilizon.Service.ActivityPub.Converters.Flag do + @moduledoc """ + Flag converter + + This module allows to convert reports from ActivityStream format to our own internal one, and back. + + Note: Reports are named Flag in AS. + """ + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Events + alias Mobilizon.Events.Event + alias Mobilizon.Reports.Report + alias Mobilizon.Service.ActivityPub.Converter + + @behaviour Converter + + @doc """ + Converts an AP object data to our internal data structure + """ + @impl Converter + @spec as_to_model_data(map()) :: map() + def as_to_model_data(object) do + with params <- as_to_model(object) do + %{ + "reporter_id" => params["reporter"].id, + "uri" => params["uri"], + "content" => params["content"], + "reported_id" => params["reported"].id, + "event_id" => (!is_nil(params["event"]) && params["event"].id) || nil, + "comments" => params["comments"] + } + end + end + + def as_to_model(%{"object" => objects} = object) do + with {:ok, %Actor{} = reporter} <- Actors.get_actor_by_url(object["actor"]), + %Actor{} = reported <- + Enum.reduce_while(objects, nil, fn url, _ -> + with {:ok, %Actor{} = actor} <- Actors.get_actor_by_url(url) do + {:halt, actor} + else + _ -> {:cont, nil} + end + end), + event <- + Enum.reduce_while(objects, nil, fn url, _ -> + with %Event{} = event <- Events.get_event_by_url(url) do + {:halt, event} + else + _ -> {:cont, nil} + end + end), + + # Remove the reported user from the object list. + comments <- + Enum.filter(objects, fn url -> + !(url == reported.url || (!is_nil(event) && event.url == url)) + end), + comments <- Enum.map(comments, &Events.get_comment_from_url/1) do + %{ + "reporter" => reporter, + "uri" => object["id"], + "content" => object["content"], + "reported" => reported, + "event" => event, + "comments" => comments + } + end + end + + @doc """ + Convert an event struct to an ActivityStream representation + """ + @impl Converter + @spec model_to_as(EventModel.t()) :: map() + def model_to_as(%Report{} = report) do + %{ + "type" => "Flag", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "actor" => report.reporter.url, + "id" => report.url + } + end +end diff --git a/lib/service/activity_pub/transmogrifier.ex b/lib/service/activity_pub/transmogrifier.ex index d11d60613..b35dc76ac 100644 --- a/lib/service/activity_pub/transmogrifier.ex +++ b/lib/service/activity_pub/transmogrifier.ex @@ -116,6 +116,22 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do |> Map.put("tag", combined) end + def handle_incoming(%{"type" => "Flag"} = data) do + with params <- Mobilizon.Service.ActivityPub.Converters.Flag.as_to_model(data) do + params = %{ + reporter_url: params["reporter"].url, + reported_actor_url: params["reported"].url, + comments_url: params["comments"] |> Enum.map(& &1.url), + content: params["content"] || "", + additional: %{ + "cc" => [params["reported"].url] + } + } + + ActivityPub.flag(params) + end + end + # TODO: validate those with a Ecto scheme # - tags # - emoji diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index 7c02e82a9..47a40c959 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -18,6 +18,10 @@ defmodule Mobilizon.Service.ActivityPub.Utils do alias Mobilizon.Media.Picture alias Mobilizon.Events alias Mobilizon.Activity + alias Mobilizon.Reports + alias Mobilizon.Reports.Report + alias Mobilizon.Users + alias Mobilizon.Service.ActivityPub.Converters alias Ecto.Changeset require Logger alias MobilizonWeb.Router.Helpers, as: Routes @@ -119,9 +123,9 @@ defmodule Mobilizon.Service.ActivityPub.Utils do def insert_full_object(%{"object" => %{"type" => "Event"} = object_data}) when is_map(object_data) do with object_data <- - Mobilizon.Service.ActivityPub.Converters.Event.as_to_model_data(object_data), - {:ok, _} <- Events.create_event(object_data) do - :ok + Converters.Event.as_to_model_data(object_data), + {:ok, %Event{} = event} <- Events.create_event(object_data) do + {:ok, event} end end @@ -129,8 +133,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do when is_map(object_data) do with object_data <- Map.put(object_data, "preferred_username", object_data["preferredUsername"]), - {:ok, _} <- Actors.create_group(object_data) do - :ok + {:ok, %Actor{} = group} <- Actors.create_group(object_data) do + {:ok, group} end end @@ -139,9 +143,9 @@ defmodule Mobilizon.Service.ActivityPub.Utils do """ def insert_full_object(%{"object" => %{"type" => "Note"} = object_data}) when is_map(object_data) do - with data <- Mobilizon.Service.ActivityPub.Converters.Comment.as_to_model_data(object_data), - {:ok, _comment} <- Events.create_comment(data) do - :ok + with data <- Converters.Comment.as_to_model_data(object_data), + {:ok, %Comment{} = comment} <- Events.create_comment(data) do + {:ok, comment} else err -> Logger.error("Error while inserting a remote comment inside database") @@ -150,7 +154,29 @@ defmodule Mobilizon.Service.ActivityPub.Utils do end end - def insert_full_object(_), do: :ok + @doc """ + Inserts a full object if it is contained in an activity. + """ + def insert_full_object(%{"type" => "Flag"} = object_data) + when is_map(object_data) do + with data <- Converters.Flag.as_to_model_data(object_data), + {:ok, %Report{} = report} <- Reports.create_report(data) do + Enum.each(Users.list_moderators(), fn moderator -> + moderator + |> Mobilizon.Email.Admin.report(moderator, report) + |> Mobilizon.Mailer.deliver_later() + end) + + {:ok, report} + else + err -> + Logger.error("Error while inserting a remote comment inside database") + Logger.error(inspect(err)) + {:error, err} + end + end + + def insert_full_object(_), do: {:ok, nil} #### Like-related helpers @@ -497,6 +523,24 @@ defmodule Mobilizon.Service.ActivityPub.Utils do |> Map.merge(additional) end + #### Flag-related helpers + @spec make_flag_data(map(), map()) :: map() + def make_flag_data(params, additional) do + object = [params.reported_actor_url] ++ params.comments_url + + object = if params[:event_url], do: object ++ [params.event_url], else: object + + %{ + "type" => "Flag", + "id" => "#{MobilizonWeb.Endpoint.url()}/report/#{Ecto.UUID.generate()}", + "actor" => params.reporter_url, + "content" => params.content, + "object" => object, + "state" => "open" + } + |> Map.merge(additional) + end + @doc """ Converts PEM encoded keys to a public key representation """ diff --git a/lib/service/admin/action_log_service.ex b/lib/service/admin/action_log_service.ex new file mode 100644 index 000000000..b1a636853 --- /dev/null +++ b/lib/service/admin/action_log_service.ex @@ -0,0 +1,30 @@ +defmodule Mobilizon.Service.Admin.ActionLogService do + @moduledoc """ + Module to handle action log creations + """ + + alias Mobilizon.Users + alias Mobilizon.Users.User + alias Mobilizon.Actors.Actor + alias Mobilizon.Admin + alias Mobilizon.Admin.ActionLog + + @doc """ + Log an admin action + """ + @spec log_action(Actor.t(), String.t(), String.t()) :: {:ok, ActionLog.t()} + def log_action(%Actor{user_id: user_id, id: actor_id}, action, target) do + with %User{role: role} <- Users.get_user!(user_id), + {:role, true} <- {:role, role in [:administrator, :moderator]}, + {:ok, %ActionLog{} = create_action_log} <- + Admin.create_action_log(%{ + "actor_id" => actor_id, + "target_type" => to_string(target.__struct__), + "target_id" => target.id, + "action" => action, + "changes" => Map.from_struct(target) |> Map.take([:status, :uri, :content]) + }) do + {:ok, create_action_log} + end + end +end diff --git a/priv/repo/migrations/20190712125833_create_reports.exs b/priv/repo/migrations/20190712125833_create_reports.exs new file mode 100644 index 000000000..ce466b3e9 --- /dev/null +++ b/priv/repo/migrations/20190712125833_create_reports.exs @@ -0,0 +1,33 @@ +defmodule Mobilizon.Repo.Migrations.CreateReports do + use Ecto.Migration + alias Mobilizon.Reports.ReportStateEnum + + def up do + ReportStateEnum.create_type() + + create table(:reports) do + add(:content, :string) + add(:status, ReportStateEnum.type(), default: "open", null: false) + add(:uri, :string, null: false) + + add(:reported_id, references(:actors, on_delete: :delete_all), null: false) + add(:reporter_id, references(:actors, on_delete: :delete_all), null: false) + add(:manager_id, references(:actors, on_delete: :delete_all), null: true) + add(:event_id, references(:events, on_delete: :delete_all), null: true) + + timestamps() + end + + create table(:reports_comments, primary_key: false) do + add(:report_id, references(:reports, on_delete: :delete_all), null: false) + add(:comment_id, references(:comments, on_delete: :delete_all), null: false) + end + end + + def down do + drop(table(:reports_comments)) + drop(table(:reports)) + + ReportStateEnum.drop_type() + end +end diff --git a/priv/repo/migrations/20190712132438_create_report_notes.exs b/priv/repo/migrations/20190712132438_create_report_notes.exs new file mode 100644 index 000000000..cd4ea63c6 --- /dev/null +++ b/priv/repo/migrations/20190712132438_create_report_notes.exs @@ -0,0 +1,13 @@ +defmodule Mobilizon.Repo.Migrations.CreateReportNotes do + use Ecto.Migration + + def change do + create table(:report_notes) do + add(:content, :string, null: false) + add(:moderator_id, references(:actors, on_delete: :delete_all), null: false) + add(:report_id, references(:reports, on_delete: :delete_all), null: false) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20190718122556_create_admin_action_logs.exs b/priv/repo/migrations/20190718122556_create_admin_action_logs.exs new file mode 100644 index 000000000..00af8f2ba --- /dev/null +++ b/priv/repo/migrations/20190718122556_create_admin_action_logs.exs @@ -0,0 +1,15 @@ +defmodule Mobilizon.Repo.Migrations.CreateAdminActionLogs do + use Ecto.Migration + + def change do + create table(:admin_action_logs) do + add(:action, :string, null: false) + add(:target_type, :string, null: false) + add(:target_id, :int, null: false) + add(:changes, :map) + add(:actor_id, references(:actors, on_delete: :nilify_all), null: false) + + timestamps() + end + end +end diff --git a/test/mobilizon/service/activity_pub/transmogrifier_test.exs b/test/mobilizon/service/activity_pub/transmogrifier_test.exs index 7ff664ecb..ffd8b32cf 100644 --- a/test/mobilizon/service/activity_pub/transmogrifier_test.exs +++ b/test/mobilizon/service/activity_pub/transmogrifier_test.exs @@ -676,6 +676,29 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do # :error = Transmogrifier.handle_incoming(data) # end + + test "it accepts Flag activities" do + %Actor{url: reporter_url} = _reporter = insert(:actor) + %Actor{url: reported_url} = reported = insert(:actor) + + %Comment{url: comment_url} = _comment = insert(:comment, actor: reported) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "cc" => [reported_url], + "object" => [reported_url, comment_url], + "type" => "Flag", + "content" => "blocked AND reported!!!", + "actor" => reporter_url + } + + assert {:ok, activity, _} = Transmogrifier.handle_incoming(message) + + assert activity.data["object"] == [reported_url, comment_url] + assert activity.data["content"] == "blocked AND reported!!!" + assert activity.data["actor"] == reporter_url + assert activity.data["cc"] == [reported_url] + end end describe "prepare outgoing" do diff --git a/test/mobilizon/service/admin/action_log_service_test.exs b/test/mobilizon/service/admin/action_log_service_test.exs new file mode 100644 index 000000000..ccdfba86b --- /dev/null +++ b/test/mobilizon/service/admin/action_log_service_test.exs @@ -0,0 +1,43 @@ +defmodule Mobilizon.Service.Admin.ActionLogServiceTest do + @moduledoc """ + Test the ActionLogService module + """ + use Mobilizon.DataCase + import Mobilizon.Service.Admin.ActionLogService + alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Admin.ActionLog + import Mobilizon.Factory + + setup do + moderator_user = insert(:user, role: :moderator) + moderator_actor = insert(:actor, user: moderator_user) + {:ok, moderator: moderator_actor} + end + + describe "action_log_creation" do + test "log a report update", %{moderator: moderator} do + %Report{id: _report_id} = report = insert(:report) + + assert {:ok, + %ActionLog{ + target_type: "Elixir.Mobilizon.Reports.Report", + target_id: report_id, + action: "update", + actor: moderator + }} = log_action(moderator, "update", report) + end + + test "log the creation of a report note", %{moderator: moderator} do + %Report{} = report = insert(:report) + %Note{id: _note_id} = report = insert(:report_note, report: report) + + assert {:ok, + %ActionLog{ + target_type: "Elixir.Mobilizon.Reports.Note", + target_id: note_id, + action: "create", + actor: moderator + }} = log_action(moderator, "create", report) + end + end +end diff --git a/test/mobilizon_web/api/report_test.exs b/test/mobilizon_web/api/report_test.exs new file mode 100644 index 000000000..fa547024b --- /dev/null +++ b/test/mobilizon_web/api/report_test.exs @@ -0,0 +1,217 @@ +defmodule MobilizonWeb.API.ReportTest do + use Mobilizon.DataCase + + alias Mobilizon.Events.Event + alias Mobilizon.Events.Comment + alias Mobilizon.Actors.Actor + alias MobilizonWeb.API.Reports + alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Activity + alias Mobilizon.Users.User + alias Mobilizon.Users + + import Mobilizon.Factory + + describe "reports" do + test "creates a report on a event" do + %Actor{id: reporter_id, url: reporter_url} = insert(:actor) + %Actor{id: reported_id, url: reported_url} = reported = insert(:actor) + + %Event{id: event_id, url: event_url} = _event = insert(:event, organizer_actor: reported) + + comment = "This is not acceptable" + + assert {:ok, %Activity{} = flag_activity, _} = + Reports.report(%{ + reporter_actor_id: reporter_id, + reported_actor_id: reported_id, + report_content: comment, + event_id: event_id, + comments_ids: [] + }) + + assert %Activity{ + actor: ^reporter_url, + data: %{ + "type" => "Flag", + "cc" => [], + "content" => ^comment, + "object" => [^reported_url, ^event_url], + "state" => "open" + } + } = flag_activity + end + + test "creates a report on several comments" do + %Actor{id: reporter_id, url: reporter_url} = insert(:actor) + %Actor{id: reported_id, url: reported_url} = reported = insert(:actor) + + %Comment{id: comment_1_id, url: comment_1_url} = + _comment_1 = insert(:comment, actor: reported) + + %Comment{id: comment_2_id, url: comment_2_url} = + _comment_2 = insert(:comment, actor: reported) + + comment = "This is really not acceptable" + + assert {:ok, %Activity{} = flag_activity, _} = + Reports.report(%{ + reporter_actor_id: reporter_id, + reported_actor_id: reported_id, + report_content: comment, + event_id: nil, + comments_ids: [comment_1_id, comment_2_id] + }) + + assert %Activity{ + actor: ^reporter_url, + data: %{ + "type" => "Flag", + "cc" => [], + "content" => ^comment, + "object" => [^reported_url, ^comment_1_url, ^comment_2_url], + "state" => "open" + } + } = flag_activity + end + + test "creates a report that gets federated" do + %Actor{id: reporter_id, url: reporter_url} = insert(:actor) + %Actor{id: reported_id, url: reported_url} = reported = insert(:actor) + + %Comment{id: comment_1_id, url: comment_1_url} = + _comment_1 = insert(:comment, actor: reported) + + %Comment{id: comment_2_id, url: comment_2_url} = + _comment_2 = insert(:comment, actor: reported) + + comment = "This is really not acceptable, remote admin I don't know" + encoded_comment = Mobilizon.Service.Formatter.html_escape(comment, "text/plain") + + assert {:ok, %Activity{} = flag_activity, _} = + Reports.report(%{ + reporter_actor_id: reporter_id, + reported_actor_id: reported_id, + report_content: comment, + event_id: nil, + comments_ids: [comment_1_id, comment_2_id], + forward: true + }) + + assert %Activity{ + actor: ^reporter_url, + data: %{ + "type" => "Flag", + "actor" => ^reporter_url, + "cc" => [^reported_url], + "content" => ^encoded_comment, + "object" => [^reported_url, ^comment_1_url, ^comment_2_url], + "state" => "open" + } + } = flag_activity + end + + test "updates report state" do + %Actor{id: reporter_id} = insert(:actor) + %Actor{id: reported_id} = reported = insert(:actor) + + %Comment{id: comment_1_id} = _comment_1 = insert(:comment, actor: reported) + + assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} = + Reports.report(%{ + reporter_actor_id: reporter_id, + reported_actor_id: reported_id, + report_content: "This is not a nice thing", + event_id: nil, + comments_ids: [comment_1_id], + forward: true + }) + + %Report{} = report = Mobilizon.Reports.get_report(report_id) + + %User{} = manager_user = insert(:user, role: :moderator) + %Actor{} = manager_actor = insert(:actor, user: manager_user) + + {:ok, new_report} = Reports.update_report_status(manager_actor, report, :resolved) + + assert new_report.status == :resolved + end + + test "updates report state with not acceptable status" do + %Actor{id: reporter_id} = insert(:actor) + %Actor{id: reported_id} = reported = insert(:actor) + + %Comment{id: comment_1_id} = _comment_1 = insert(:comment, actor: reported) + + assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} = + Reports.report(%{ + reporter_actor_id: reporter_id, + reported_actor_id: reported_id, + report_content: "This is not a nice thing", + event_id: nil, + comments_ids: [comment_1_id], + forward: true + }) + + %Report{} = report = Mobilizon.Reports.get_report(report_id) + + %Actor{} = manager_actor = insert(:actor) + + {:error, "Unsupported state"} = Reports.update_report_status(manager_actor, report, :test) + end + end + + describe "note reports" do + test "creates a note on a report" do + %User{} = moderator_user = insert(:user, role: :moderator) + %Actor{} = moderator_actor = insert(:actor, user: moderator_user) + %Report{} = report = insert(:report) + + assert {:ok, %Note{} = _note} = + Reports.create_report_note( + report, + moderator_actor, + "I'll take care of this later today" + ) + end + + test "doesn't create a note on a report when not moderator" do + %User{} = user = insert(:user) + %Actor{} = actor = insert(:actor, user: user) + %Report{} = report = insert(:report) + + assert {:error, + "You need to be a moderator or an administrator to create a note on a report"} = + Reports.create_report_note(report, actor, "I'll take care of this later today") + end + + test "deletes a note on a report" do + %User{} = moderator_user = insert(:user, role: :moderator) + %Actor{} = moderator_actor = insert(:actor, user: moderator_user) + %Note{} = note = insert(:report_note, moderator: moderator_actor) + assert {:ok, %Note{}} = Reports.delete_report_note(note, moderator_actor) + end + + test "deletes a note on a report with a different moderator actor" do + %Note{} = note = insert(:report_note) + + %User{} = other_moderator_user = insert(:user, role: :moderator) + %Actor{} = other_moderator_actor = insert(:actor, user: other_moderator_user) + + assert {:error, "You can only remove your own notes"} = + Reports.delete_report_note(note, other_moderator_actor) + end + + test "try deletes a note on a report with a actor not moderator anymore" do + %User{} = moderator_user = insert(:user, role: :moderator) + %Actor{} = moderator_actor = insert(:actor, user: moderator_user) + %Note{} = note = insert(:report_note, moderator: moderator_actor) + + Users.update_user(moderator_user, %{role: :user}) + + assert {:error, + "You need to be a moderator or an administrator to create a note on a report"} = + Reports.delete_report_note(note, moderator_actor) + end + end +end diff --git a/test/mobilizon_web/resolvers/admin_resolver_test.exs b/test/mobilizon_web/resolvers/admin_resolver_test.exs new file mode 100644 index 000000000..150cb7bc4 --- /dev/null +++ b/test/mobilizon_web/resolvers/admin_resolver_test.exs @@ -0,0 +1,82 @@ +defmodule MobilizonWeb.Resolvers.AdminResolverTest do + alias MobilizonWeb.AbsintheHelpers + use MobilizonWeb.ConnCase + import Mobilizon.Factory + + alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User + alias Mobilizon.Reports.{Report, Note} + + describe "Resolver: List the action logs" do + @note_content "This a note on a report" + test "list_action_logs/3 list action logs", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{} = moderator = insert(:actor, user: user_moderator) + + %User{} = user_moderator_2 = insert(:user, role: :moderator) + %Actor{} = moderator_2 = insert(:actor, user: user_moderator_2) + + %Report{} = report = insert(:report) + MobilizonWeb.API.Reports.update_report_status(moderator, report, "resolved") + + {:ok, %Note{} = note} = + MobilizonWeb.API.Reports.create_report_note(report, moderator_2, @note_content) + + MobilizonWeb.API.Reports.delete_report_note(note, moderator_2) + + query = """ + { + actionLogs { + action, + actor { + preferredUsername + }, + object { + ... on Report { + id, + status + }, + ... on ReportNote { + content + } + } + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs")) + + assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == + "You need to be logged-in and a moderator to list action logs" + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs")) + + assert json_response(res, 200)["errors"] == nil + + assert json_response(res, 200)["data"]["actionLogs"] |> length == 3 + + assert json_response(res, 200)["data"]["actionLogs"] == [ + %{ + "action" => "report_update_resolved", + "actor" => %{"preferredUsername" => moderator.preferred_username}, + "object" => %{"id" => to_string(report.id), "status" => "RESOLVED"} + }, + %{ + "action" => "note_creation", + "actor" => %{"preferredUsername" => moderator_2.preferred_username}, + "object" => %{"content" => @note_content} + }, + %{ + "action" => "note_deletion", + "actor" => %{"preferredUsername" => moderator_2.preferred_username}, + "object" => %{"content" => @note_content} + } + ] + end + end +end diff --git a/test/mobilizon_web/resolvers/report_resolver_test.exs b/test/mobilizon_web/resolvers/report_resolver_test.exs new file mode 100644 index 000000000..915ccbbe0 --- /dev/null +++ b/test/mobilizon_web/resolvers/report_resolver_test.exs @@ -0,0 +1,366 @@ +defmodule MobilizonWeb.Resolvers.ReportResolverTest do + alias MobilizonWeb.AbsintheHelpers + use MobilizonWeb.ConnCase + import Mobilizon.Factory + + alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User + alias Mobilizon.Events.Event + alias Mobilizon.Reports.{Report, Note} + + describe "Resolver: Report a content" do + test "create_report/3 creates a report", %{conn: conn} do + %User{} = user_reporter = insert(:user) + %Actor{} = reporter = insert(:actor, user: user_reporter) + %Actor{} = reported = insert(:actor) + %Event{} = event = insert(:event, organizer_actor: reported) + + mutation = """ + mutation { + createReport( + reporter_actor_id: #{reporter.id}, + reported_actor_id: #{reported.id}, + event_id: #{event.id}, + report_content: "This is an issue" + ) { + content, + reporter { + id + }, + event { + id + }, + status + } + } + """ + + res = + conn + |> auth_conn(user_reporter) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["createReport"]["content"] == "This is an issue" + assert json_response(res, 200)["data"]["createReport"]["status"] == "OPEN" + assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == event.id + assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] == reporter.id + end + + test "create_report/3 without being connected doesn't create any report", %{conn: conn} do + %Actor{} = reported = insert(:actor) + + mutation = """ + mutation { + createReport( + reported_actor_id: #{reported.id}, + reporter_actor_id: 5, + report_content: "This is an issue" + ) { + content + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == + "You need to be logged-in to create reports" + end + end + + describe "Resolver: update a report" do + test "update_report/3 updates the report's status", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{} = moderator = insert(:actor, user: user_moderator) + %Report{} = report = insert(:report) + + mutation = """ + mutation { + updateReportStatus( + report_id: #{report.id}, + moderator_id: #{moderator.id}, + status: RESOLVED + ) { + content, + reporter { + id + }, + event { + id + }, + status + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + + assert json_response(res, 200)["data"]["updateReportStatus"]["content"] == + "This is problematic" + + assert json_response(res, 200)["data"]["updateReportStatus"]["status"] == "RESOLVED" + + assert json_response(res, 200)["data"]["updateReportStatus"]["reporter"]["id"] == + report.reporter.id + end + + test "create_report/3 without being connected doesn't create any report", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{} = moderator = insert(:actor, user: user_moderator) + %Report{} = report = insert(:report) + + mutation = """ + mutation { + updateReportStatus( + report_id: #{report.id}, + moderator_id: #{moderator.id}, + status: RESOLVED + ) { + content + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == + "You need to be logged-in and a moderator to update a report" + end + end + + describe "Resolver: Get list of reports" do + test "get an empty list of reports", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + + query = """ + { + reports { + id, + reported { + preferredUsername + } + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == + "You need to be logged-in and a moderator to list reports" + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["reports"] == [] + end + + test "get a list of reports", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + + %Report{id: report_1_id} = insert(:report) + %Report{id: report_2_id} = insert(:report) + %Report{id: report_3_id} = insert(:report) + + query = """ + { + reports { + id, + reported { + preferredUsername + } + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] == nil + + assert json_response(res, 200)["data"]["reports"] + |> Enum.map(fn report -> Map.get(report, "id") end) == + Enum.map([report_1_id, report_2_id, report_3_id], &to_string/1) + + query = """ + { + reports(page: 2, limit: 2) { + id, + reported { + preferredUsername + } + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["reports"] |> length == 1 + + query = """ + { + reports(page: 3, limit: 2) { + id, + reported { + preferredUsername + } + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["reports"] |> length == 0 + end + end + + describe "Resolver: View a report" do + test "view a report", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{} = insert(:actor, user: user_moderator) + %Actor{} = reporter = insert(:actor) + %Report{} = report = insert(:report, reporter: reporter) + + query = """ + { + report (id: "#{report.id}") { + id, + reported { + preferredUsername + }, + reporter { + preferredUsername + }, + event { + title + }, + comments { + text + }, + content + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == + "You need to be logged-in and a moderator to view a report" + + res = + conn + |> auth_conn(user_moderator) + |> get("/api", AbsintheHelpers.query_skeleton(query, "report")) + + assert json_response(res, 200)["errors"] == nil + + assert json_response(res, 200)["data"]["report"]["reported"]["preferredUsername"] == + report.reported.preferred_username + + assert json_response(res, 200)["data"]["report"]["reporter"]["preferredUsername"] == + reporter.preferred_username + + assert json_response(res, 200)["data"]["report"]["content"] == report.content + assert json_response(res, 200)["data"]["report"]["event"]["title"] == report.event.title + + assert json_response(res, 200)["data"]["report"]["comments"] |> hd |> Map.get("text") == + report.comments |> hd |> Map.get(:text) + end + end + + describe "Resolver: Add a note on a report" do + @report_note_content "I agree with this this report" + + test "create_report_note/3 creates a report note", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{id: moderator_id} = moderator = insert(:actor, user: user_moderator) + %Report{id: report_id} = insert(:report) + + mutation = """ + mutation { + createReportNote( + moderator_id: #{moderator_id}, + report_id: #{report_id}, + content: "#{@report_note_content}" + ) { + content, + moderator { + preferred_username + }, + report { + id + } + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + + assert json_response(res, 200)["data"]["createReportNote"]["content"] == + @report_note_content + + assert json_response(res, 200)["data"]["createReportNote"]["moderator"][ + "preferred_username" + ] == moderator.preferred_username + + assert json_response(res, 200)["data"]["createReportNote"]["report"]["id"] == + to_string(report_id) + end + + test "delete_report_note deletes a report note", %{conn: conn} do + %User{} = user_moderator = insert(:user, role: :moderator) + %Actor{id: moderator_id} = moderator = insert(:actor, user: user_moderator) + %Note{id: report_note_id} = insert(:report_note, moderator: moderator) + + mutation = """ + mutation { + deleteReportNote( + moderator_id: #{moderator_id}, + note_id: #{report_note_id}, + ) { + id + } + } + """ + + res = + conn + |> auth_conn(user_moderator) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["deleteReportNote"]["id"] == report_note_id + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 002566d2f..0d417cdda 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -202,4 +202,24 @@ defmodule Mobilizon.Factory do actor: build(:actor) } end + + def report_factory do + %Mobilizon.Reports.Report{ + content: "This is problematic", + status: :open, + uri: "http://mobilizon.test/report/deae1020-54b8-47df-9eea-d8c0e943e57f/activity", + reported: build(:actor), + reporter: build(:actor), + event: build(:event), + comments: build_list(1, :comment) + } + end + + def report_note_factory do + %Mobilizon.Reports.Note{ + content: "My opinion", + moderator: build(:actor), + report: build(:report) + } + end end