Introduce backend for reports

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-07-23 13:49:22 +02:00
parent 33a8da4570
commit aef841e192
36 changed files with 2028 additions and 36 deletions

View File

@@ -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

48
lib/mobilizon/admin.ex Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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.

236
lib/mobilizon/reports.ex Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,15 @@
<h1><%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %></h1>
<% if @report.event do %>
<p><%= gettext "Event: %{event}", event: @report.event %></p>
<% end %>
<%= for comment <- @report.comments do %>
<p><%= gettext "Comment: %{comment}", comment: comment %></p>
<% end %>
<% if @content do %>
<p><%= gettext "Reason: %{content}", event: @report.content %></p>
<% end %>
<p><%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %></p>

View File

@@ -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" %>

View File

@@ -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)
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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