Merge branch 'feature/report-notes' into 'develop'

AdminAPI: Add report notes

See merge request pleroma/pleroma!2031
This commit is contained in:
feld 2019-12-11 23:45:44 +00:00
commit 2aa609db8a
15 changed files with 273 additions and 152 deletions

View File

@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- **Breaking:** Admin API: Return link alongside with token on password reset - **Breaking:** Admin API: Return link alongside with token on password reset
- **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details - **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details
- **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string. - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string.
- **Breaking** replying to reports is now "report notes", enpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes`
- Admin API: Return `total` when querying for reports - Admin API: Return `total` when querying for reports
- Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`) - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`)
- Admin API: Return link alongside with token on password reset - Admin API: Return link alongside with token on password reset

View File

@ -614,78 +614,29 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- On success: `204`, empty response - On success: `204`, empty response
## `POST /api/pleroma/admin/reports/:id/respond` ## `POST /api/pleroma/admin/reports/:id/notes`
### Respond to a report ### Create report note
- Params: - Params:
- `id` - `id`: required, report id
- `status`: required, the message - `content`: required, the message
- Response: - Response:
- On failure: - On failure:
- 400 Bad Request `"Invalid parameters"` when `status` is missing - 400 Bad Request `"Invalid parameters"` when `status` is missing
- 403 Forbidden `{"error": "error_msg"}` - On success: `204`, empty response
- 404 Not Found `"Not found"`
- On success: JSON, created Mastodon Status entity
```json ## `POST /api/pleroma/admin/reports/:report_id/notes/:id`
{
"account": { ... }, ### Delete report note
"application": {
"name": "Web", - Params:
"website": null - `report_id`: required, report id
}, - `id`: required, note id
"bookmarked": false, - Response:
"card": null, - On failure:
"content": "Your claim is going to be closed", - 400 Bad Request `"Invalid parameters"` when `status` is missing
"created_at": "2019-05-11T17:13:03.000Z", - On success: `204`, empty response
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9ihuiSL1405I65TmEq",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "user",
"id": "9i6dAJqSGSKMzLG2Lo",
"url": "https://pleroma.example.org/users/user",
"username": "user"
},
{
"acct": "admin",
"id": "9hEkA5JsvAdlSrocam",
"url": "https://pleroma.example.org/users/admin",
"username": "admin"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Your claim is going to be closed"
},
"conversation_id": 35,
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
}
},
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"uri": "https://pleroma.example.org/objects/cab0836d-9814-46cd-a0ea-529da9db5fcb",
"url": "https://pleroma.example.org/notice/9ihuiSL1405I65TmEq",
"visibility": "direct"
}
```
## `PUT /api/pleroma/admin/statuses/:id` ## `PUT /api/pleroma/admin/statuses/:id`

View File

@ -12,6 +12,7 @@ defmodule Pleroma.Activity do
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.ReportNote
alias Pleroma.ThreadMute alias Pleroma.ThreadMute
alias Pleroma.User alias Pleroma.User
@ -48,6 +49,8 @@ defmodule Pleroma.Activity do
has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id) has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id)
# This is a fake relation, do not use outside of with_preloaded_bookmark/get_bookmark # This is a fake relation, do not use outside of with_preloaded_bookmark/get_bookmark
has_one(:bookmark, Bookmark) has_one(:bookmark, Bookmark)
# This is a fake relation, do not use outside of with_preloaded_report_notes
has_many(:report_notes, ReportNote)
has_many(:notifications, Notification, on_delete: :delete_all) has_many(:notifications, Notification, on_delete: :delete_all)
# Attention: this is a fake relation, don't try to preload it blindly and expect it to work! # Attention: this is a fake relation, don't try to preload it blindly and expect it to work!
@ -114,6 +117,16 @@ def with_preloaded_bookmark(query, %User{} = user) do
def with_preloaded_bookmark(query, _), do: query def with_preloaded_bookmark(query, _), do: query
def with_preloaded_report_notes(query) do
from([a] in query,
left_join: r in ReportNote,
on: a.id == r.activity_id,
preload: [report_notes: r]
)
end
def with_preloaded_report_notes(query, _), do: query
def with_set_thread_muted_field(query, %User{} = user) do def with_set_thread_muted_field(query, %User{} = user) do
from([a] in query, from([a] in query,
left_join: tm in ThreadMute, left_join: tm in ThreadMute,

View File

@ -128,17 +128,35 @@ def insert_log(%{
{:ok, ModerationLog} | {:error, any} {:ok, ModerationLog} | {:error, any}
def insert_log(%{ def insert_log(%{
actor: %User{} = actor, actor: %User{} = actor,
action: "report_response", action: "report_note",
subject: %Activity{} = subject, subject: %Activity{} = subject,
text: text text: text
}) do }) do
%ModerationLog{ %ModerationLog{
data: %{ data: %{
"actor" => user_to_map(actor), "actor" => user_to_map(actor),
"action" => "report_response", "action" => "report_note",
"subject" => report_to_map(subject), "subject" => report_to_map(subject),
"text" => text, "text" => text
"message" => "" }
}
|> insert_log_entry_with_message()
end
@spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) ::
{:ok, ModerationLog} | {:error, any}
def insert_log(%{
actor: %User{} = actor,
action: "report_note_delete",
subject: %Activity{} = subject,
text: text
}) do
%ModerationLog{
data: %{
"actor" => user_to_map(actor),
"action" => "report_note_delete",
"subject" => report_to_map(subject),
"text" => text
} }
} }
|> insert_log_entry_with_message() |> insert_log_entry_with_message()
@ -556,12 +574,24 @@ def get_log_entry_message(%ModerationLog{
def get_log_entry_message(%ModerationLog{ def get_log_entry_message(%ModerationLog{
data: %{ data: %{
"actor" => %{"nickname" => actor_nickname}, "actor" => %{"nickname" => actor_nickname},
"action" => "report_response", "action" => "report_note",
"subject" => %{"id" => subject_id, "type" => "report"}, "subject" => %{"id" => subject_id, "type" => "report"},
"text" => text "text" => text
} }
}) do }) do
"@#{actor_nickname} responded with '#{text}' to report ##{subject_id}" "@#{actor_nickname} added note '#{text}' to report ##{subject_id}"
end
@spec get_log_entry_message(ModerationLog) :: String.t()
def get_log_entry_message(%ModerationLog{
data: %{
"actor" => %{"nickname" => actor_nickname},
"action" => "report_note_delete",
"subject" => %{"id" => subject_id, "type" => "report"},
"text" => text
}
}) do
"@#{actor_nickname} deleted note '#{text}' from report ##{subject_id}"
end end
@spec get_log_entry_message(ModerationLog) :: String.t() @spec get_log_entry_message(ModerationLog) :: String.t()

View File

@ -38,7 +38,10 @@ def fetch_paginated(query, params, :keyset, table_binding) do
end end
def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) do def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) do
total = Repo.aggregate(query, :count, :id) total =
query
|> Ecto.Query.exclude(:left_join)
|> Repo.aggregate(:count, :id)
%{ %{
total: total, total: total,

View File

@ -0,0 +1,48 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReportNote do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.Repo
alias Pleroma.ReportNote
alias Pleroma.User
@type t :: %__MODULE__{}
schema "report_notes" do
field(:content, :string)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
timestamps()
end
@spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t(), String.t()) ::
{:ok, ReportNote.t()} | {:error, Changeset.t()}
def create(user_id, activity_id, content) do
attrs = %{
user_id: user_id,
activity_id: activity_id,
content: content
}
%ReportNote{}
|> cast(attrs, [:user_id, :activity_id, :content])
|> validate_required([:user_id, :activity_id, :content])
|> Repo.insert()
end
@spec destroy(FlakeId.Ecto.CompatType.t()) ::
{:ok, ReportNote.t()} | {:error, Changeset.t()}
def destroy(id) do
from(r in ReportNote, where: r.id == ^id)
|> Repo.one()
|> Repo.delete()
end
end

View File

@ -1068,6 +1068,13 @@ defp maybe_preload_bookmarks(query, opts) do
|> Activity.with_preloaded_bookmark(opts["user"]) |> Activity.with_preloaded_bookmark(opts["user"])
end end
defp maybe_preload_report_notes(query, %{"preload_report_notes" => true}) do
query
|> Activity.with_preloaded_report_notes()
end
defp maybe_preload_report_notes(query, _), do: query
defp maybe_set_thread_muted_field(query, %{"skip_preload" => true}), do: query defp maybe_set_thread_muted_field(query, %{"skip_preload" => true}), do: query
defp maybe_set_thread_muted_field(query, opts) do defp maybe_set_thread_muted_field(query, opts) do
@ -1121,6 +1128,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do
Activity Activity
|> maybe_preload_objects(opts) |> maybe_preload_objects(opts)
|> maybe_preload_bookmarks(opts) |> maybe_preload_bookmarks(opts)
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts) |> maybe_set_thread_muted_field(opts)
|> maybe_order(opts) |> maybe_order(opts)
|> restrict_recipients(recipients, opts["user"]) |> restrict_recipients(recipients, opts["user"])

View File

@ -787,6 +787,7 @@ def get_reports(params, page, page_size) do
params params
|> Map.put("type", "Flag") |> Map.put("type", "Flag")
|> Map.put("skip_preload", true) |> Map.put("skip_preload", true)
|> Map.put("preload_report_notes", true)
|> Map.put("total", true) |> Map.put("total", true)
|> Map.put("limit", page_size) |> Map.put("limit", page_size)
|> Map.put("offset", (page - 1) * page_size) |> Map.put("offset", (page - 1) * page_size)

View File

@ -7,6 +7,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.ReportNote
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -643,9 +644,11 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic
def list_reports(conn, params) do def list_reports(conn, params) do
{page, page_size} = page_params(params) {page, page_size} = page_params(params)
reports = Utils.get_reports(params, page, page_size)
conn conn
|> put_view(ReportView) |> put_view(ReportView)
|> render("index.json", %{reports: Utils.get_reports(params, page, page_size)}) |> render("index.json", %{reports: reports})
end end
def list_grouped_reports(conn, _params) do def list_grouped_reports(conn, _params) do
@ -689,32 +692,39 @@ def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) d
end end
end end
def report_respond(%{assigns: %{user: user}} = conn, %{"id" => id} = params) do def report_notes_create(%{assigns: %{user: user}} = conn, %{
with false <- is_nil(params["status"]), "id" => report_id,
%Activity{} <- Activity.get_by_id(id) do "content" => content
params = }) do
params with {:ok, _} <- ReportNote.create(user.id, report_id, content) do
|> Map.put("in_reply_to_status_id", id)
|> Map.put("visibility", "direct")
{:ok, activity} = CommonAPI.post(user, params)
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
action: "report_response", action: "report_note",
actor: user, actor: user,
subject: activity, subject: Activity.get_by_id(report_id),
text: params["status"] text: content
}) })
conn json_response(conn, :no_content, "")
|> put_view(StatusView)
|> render("show.json", %{activity: activity})
else else
true -> _ -> json_response(conn, :bad_request, "")
{:param_cast, nil} end
end
nil -> def report_notes_delete(%{assigns: %{user: user}} = conn, %{
{:error, :not_found} "id" => note_id,
"report_id" => report_id
}) do
with {:ok, note} <- ReportNote.destroy(note_id) do
ModerationLog.insert_log(%{
action: "report_note_delete",
actor: user,
subject: Activity.get_by_id(report_id),
text: note.content
})
json_response(conn, :no_content, "")
else
_ -> json_response(conn, :bad_request, "")
end end
end end

View File

@ -39,7 +39,8 @@ def render("show.json", %{report: report, user: user, account: account, statuses
content: content, content: content,
created_at: created_at, created_at: created_at,
statuses: StatusView.render("index.json", %{activities: statuses, as: :activity}), statuses: StatusView.render("index.json", %{activities: statuses, as: :activity}),
state: report.data["state"] state: report.data["state"],
notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes})
} }
end end
@ -69,6 +70,28 @@ def render("index_grouped.json", %{groups: groups}) do
} }
end end
def render("index_notes.json", %{notes: notes}) when is_list(notes) do
Enum.map(notes, &render(__MODULE__, "show_note.json", &1))
end
def render("index_notes.json", _), do: []
def render("show_note.json", %{
id: id,
content: content,
user_id: user_id,
inserted_at: inserted_at
}) do
user = User.get_by_id(user_id)
%{
id: id,
content: content,
user: merge_account_views(user),
created_at: Utils.to_masto_date(inserted_at)
}
end
defp merge_account_views(%User{} = user) do defp merge_account_views(%User{} = user) do
Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user})
|> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}))

View File

@ -187,7 +187,8 @@ defmodule Pleroma.Web.Router do
get("/grouped_reports", AdminAPIController, :list_grouped_reports) get("/grouped_reports", AdminAPIController, :list_grouped_reports)
get("/reports/:id", AdminAPIController, :report_show) get("/reports/:id", AdminAPIController, :report_show)
patch("/reports", AdminAPIController, :reports_update) patch("/reports", AdminAPIController, :reports_update)
post("/reports/:id/respond", AdminAPIController, :report_respond) post("/reports/:id/notes", AdminAPIController, :report_notes_create)
delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete)
put("/statuses/:id", AdminAPIController, :status_update) put("/statuses/:id", AdminAPIController, :status_update)
delete("/statuses/:id", AdminAPIController, :status_delete) delete("/statuses/:id", AdminAPIController, :status_delete)

View File

@ -0,0 +1,13 @@
defmodule Pleroma.Repo.Migrations.CreateReportNotes do
use Ecto.Migration
def change do
create_if_not_exists table(:report_notes) do
add(:user_id, references(:users, type: :uuid))
add(:activity_id, references(:activities, type: :uuid))
add(:content, :string)
timestamps()
end
end
end

View File

@ -214,7 +214,7 @@ test "logging report response", %{moderator: moderator} do
{:ok, _} = {:ok, _} =
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
actor: moderator, actor: moderator,
action: "report_response", action: "report_note",
subject: report, subject: report,
text: "look at this" text: "look at this"
}) })
@ -222,7 +222,7 @@ test "logging report response", %{moderator: moderator} do
log = Repo.one(ModerationLog) log = Repo.one(ModerationLog)
assert log.data["message"] == assert log.data["message"] ==
"@#{moderator.nickname} responded with 'look at this' to report ##{report.id}" "@#{moderator.nickname} added note 'look at this' to report ##{report.id}"
end end
test "logging status sensitivity update", %{moderator: moderator} do test "logging status sensitivity update", %{moderator: moderator} do

View File

@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.ReportNote
alias Pleroma.Tests.ObanHelpers alias Pleroma.Tests.ObanHelpers
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
@ -1831,61 +1832,6 @@ test "account not empty if status was deleted", %{
end end
end end
describe "POST /api/pleroma/admin/reports/:id/respond" do
setup %{conn: conn} do
admin = insert(:user, is_admin: true)
%{conn: assign(conn, :user, admin), admin: admin}
end
test "returns created dm", %{conn: conn, admin: admin} do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
"account_id" => target_user.id,
"comment" => "I feel offended",
"status_ids" => [activity.id]
})
response =
conn
|> post("/api/pleroma/admin/reports/#{report_id}/respond", %{
"status" => "I will check it out"
})
|> json_response(:ok)
recipients = Enum.map(response["mentions"], & &1["username"])
assert reporter.nickname in recipients
assert response["content"] == "I will check it out"
assert response["visibility"] == "direct"
log_entry = Repo.one(ModerationLog)
assert ModerationLog.get_log_entry_message(log_entry) ==
"@#{admin.nickname} responded with 'I will check it out' to report ##{
response["id"]
}"
end
test "returns 400 when status is missing", %{conn: conn} do
conn = post(conn, "/api/pleroma/admin/reports/test/respond")
assert json_response(conn, :bad_request) == "Invalid parameters"
end
test "returns 404 when report id is invalid", %{conn: conn} do
conn =
post(conn, "/api/pleroma/admin/reports/test/respond", %{
"status" => "foo"
})
assert json_response(conn, :not_found) == "Not found"
end
end
describe "PUT /api/pleroma/admin/statuses/:id" do describe "PUT /api/pleroma/admin/statuses/:id" do
setup %{conn: conn} do setup %{conn: conn} do
admin = insert(:user, is_admin: true) admin = insert(:user, is_admin: true)
@ -3082,6 +3028,77 @@ test "it resend emails for two users", %{admin: admin} do
}" }"
end end
end end
describe "POST /reports/:id/notes" do
setup do
admin = insert(:user, is_admin: true)
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %{id: report_id}} =
CommonAPI.report(reporter, %{
"account_id" => target_user.id,
"comment" => "I feel offended",
"status_ids" => [activity.id]
})
build_conn()
|> assign(:user, admin)
|> post("/api/pleroma/admin/reports/#{report_id}/notes", %{
content: "this is disgusting!"
})
build_conn()
|> assign(:user, admin)
|> post("/api/pleroma/admin/reports/#{report_id}/notes", %{
content: "this is disgusting2!"
})
%{
admin_id: admin.id,
report_id: report_id,
admin: admin
}
end
test "it creates report note", %{admin_id: admin_id, report_id: report_id} do
[note, _] = Repo.all(ReportNote)
assert %{
activity_id: ^report_id,
content: "this is disgusting!",
user_id: ^admin_id
} = note
end
test "it returns reports with notes", %{admin: admin} do
conn =
build_conn()
|> assign(:user, admin)
|> get("/api/pleroma/admin/reports")
response = json_response(conn, 200)
notes = hd(response["reports"])["notes"]
[note, _] = notes
assert note["user"]["nickname"] == admin.nickname
assert note["content"] == "this is disgusting!"
assert note["created_at"]
assert response["total"] == 1
end
test "it deletes the note", %{admin: admin, report_id: report_id} do
assert ReportNote |> Repo.all() |> length() == 2
[note, _] = Repo.all(ReportNote)
build_conn()
|> assign(:user, admin)
|> delete("/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}")
assert ReportNote |> Repo.all() |> length() == 1
end
end
end end
# Needed for testing # Needed for testing

View File

@ -30,6 +30,7 @@ test "renders a report" do
Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user}) Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user})
), ),
statuses: [], statuses: [],
notes: [],
state: "open", state: "open",
id: activity.id id: activity.id
} }
@ -65,6 +66,7 @@ test "includes reported statuses" do
), ),
statuses: [StatusView.render("show.json", %{activity: activity})], statuses: [StatusView.render("show.json", %{activity: activity})],
state: "open", state: "open",
notes: [],
id: report_activity.id id: report_activity.id
} }