Log admin/moderator actions
authorMaxim Filippov <colixer@gmail.com>
Sun, 25 Aug 2019 19:39:37 +0000 (19:39 +0000)
committerkaniini <ariadne@dereferenced.org>
Sun, 25 Aug 2019 19:39:37 +0000 (19:39 +0000)
CHANGELOG.md
docs/api/admin_api.md
lib/pleroma/moderation_log.ex [new file with mode: 0644]
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/admin_api/views/moderation_log_view.ex [new file with mode: 0644]
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/ostatus/ostatus_controller.ex
lib/pleroma/web/router.ex
priv/repo/migrations/20190818124341_create_moderation_log.exs [new file with mode: 0644]
test/moderation_log_test.exs [new file with mode: 0644]
test/web/admin_api/admin_api_controller_test.exs

index 9c3051c944a35d185592fefc93b94672d3b01df9..2fdcb014ad700b4544bf1bd3d1b556144e6e3fd4 100644 (file)
@@ -94,6 +94,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Relays: Added a task to list relay subscriptions.
 - Mix Tasks: `mix pleroma.database fix_likes_collections`
 - Federation: Remove `likes` from objects.
+- Admin API: Added moderation log
 
 ### Changed
 - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
index 7ccb9083608d5703d41759ba6534445d7709d1d1..d79c342be3c092d1337ad037fa228ef6b1324a97 100644 (file)
@@ -694,3 +694,27 @@ Compile time settings (need instance reboot):
   ]
 }
 ```
+
+## `/api/pleroma/admin/moderation_log`
+### Get moderation log
+- Method `GET`
+- Params:
+  - *optional* `page`: **integer** page number
+  - *optional* `page_size`: **integer** number of users per page (default is `50`)
+- Response:
+
+```json
+[
+  {
+    "data": {
+      "actor": {
+        "id": 1,
+        "nickname": "lain"
+      },
+      "action": "relay_follow"
+    },
+    "time": 1502812026, // timestamp
+    "message": "[2017-08-15 15:47:06] @nick0 followed relay: https://example.org/relay" // log message
+  }
+]
+```
diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex
new file mode 100644 (file)
index 0000000..1ef6fe6
--- /dev/null
@@ -0,0 +1,433 @@
+defmodule Pleroma.ModerationLog do
+  use Ecto.Schema
+
+  alias Pleroma.Activity
+  alias Pleroma.ModerationLog
+  alias Pleroma.Repo
+  alias Pleroma.User
+
+  import Ecto.Query
+
+  schema "moderation_log" do
+    field(:data, :map)
+
+    timestamps()
+  end
+
+  def get_all(page, page_size) do
+    from(q in __MODULE__,
+      order_by: [desc: q.inserted_at],
+      limit: ^page_size,
+      offset: ^((page - 1) * page_size)
+    )
+    |> Repo.all()
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        subject: %User{} = subject,
+        action: action,
+        permission: permission
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        subject: user_to_map(subject),
+        action: action,
+        permission: permission
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        action: "report_update",
+        subject: %Activity{data: %{"type" => "Flag"}} = subject
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "report_update",
+        subject: report_to_map(subject)
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        action: "report_response",
+        subject: %Activity{} = subject,
+        text: text
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "report_response",
+        subject: report_to_map(subject),
+        text: text
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        action: "status_update",
+        subject: %Activity{} = subject,
+        sensitive: sensitive,
+        visibility: visibility
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "status_update",
+        subject: status_to_map(subject),
+        sensitive: sensitive,
+        visibility: visibility
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        action: "status_delete",
+        subject_id: subject_id
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "status_delete",
+        subject_id: subject_id
+      }
+    })
+  end
+
+  @spec insert_log(%{actor: User, subject: User, action: String.t()}) ::
+          {:ok, ModerationLog} | {:error, any}
+  def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: action,
+        subject: user_to_map(subject)
+      }
+    })
+  end
+
+  @spec insert_log(%{actor: User, subjects: [User], action: String.t()}) ::
+          {:ok, ModerationLog} | {:error, any}
+  def insert_log(%{actor: %User{} = actor, subjects: subjects, action: action}) do
+    subjects = Enum.map(subjects, &user_to_map/1)
+
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: action,
+        subjects: subjects
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        followed: %User{} = followed,
+        follower: %User{} = follower,
+        action: "follow"
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "follow",
+        followed: user_to_map(followed),
+        follower: user_to_map(follower)
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        followed: %User{} = followed,
+        follower: %User{} = follower,
+        action: "unfollow"
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: "unfollow",
+        followed: user_to_map(followed),
+        follower: user_to_map(follower)
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        nicknames: nicknames,
+        tags: tags,
+        action: action
+      }) do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        nicknames: nicknames,
+        tags: tags,
+        action: action
+      }
+    })
+  end
+
+  def insert_log(%{
+        actor: %User{} = actor,
+        action: action,
+        target: target
+      })
+      when action in ["relay_follow", "relay_unfollow"] do
+    Repo.insert(%ModerationLog{
+      data: %{
+        actor: user_to_map(actor),
+        action: action,
+        target: target
+      }
+    })
+  end
+
+  defp user_to_map(%User{} = user) do
+    user
+    |> Map.from_struct()
+    |> Map.take([:id, :nickname])
+    |> Map.put(:type, "user")
+  end
+
+  defp report_to_map(%Activity{} = report) do
+    %{
+      type: "report",
+      id: report.id,
+      state: report.data["state"]
+    }
+  end
+
+  defp status_to_map(%Activity{} = status) do
+    %{
+      type: "status",
+      id: status.id
+    }
+  end
+
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => action,
+          "followed" => %{"nickname" => followed_nickname},
+          "follower" => %{"nickname" => follower_nickname}
+        }
+      }) do
+    "@#{actor_nickname} made @#{follower_nickname} #{action} @#{followed_nickname}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "delete",
+          "subject" => %{"nickname" => subject_nickname, "type" => "user"}
+        }
+      }) do
+    "@#{actor_nickname} deleted user @#{subject_nickname}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "create",
+          "subjects" => subjects
+        }
+      }) do
+    nicknames =
+      subjects
+      |> Enum.map(&"@#{&1["nickname"]}")
+      |> Enum.join(", ")
+
+    "@#{actor_nickname} created users: #{nicknames}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "activate",
+          "subject" => %{"nickname" => subject_nickname, "type" => "user"}
+        }
+      }) do
+    "@#{actor_nickname} activated user @#{subject_nickname}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "deactivate",
+          "subject" => %{"nickname" => subject_nickname, "type" => "user"}
+        }
+      }) do
+    "@#{actor_nickname} deactivated user @#{subject_nickname}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "nicknames" => nicknames,
+          "tags" => tags,
+          "action" => "tag"
+        }
+      }) do
+    nicknames_string =
+      nicknames
+      |> Enum.map(&"@#{&1}")
+      |> Enum.join(", ")
+
+    tags_string = tags |> Enum.join(", ")
+
+    "@#{actor_nickname} added tags: #{tags_string} to users: #{nicknames_string}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "nicknames" => nicknames,
+          "tags" => tags,
+          "action" => "untag"
+        }
+      }) do
+    nicknames_string =
+      nicknames
+      |> Enum.map(&"@#{&1}")
+      |> Enum.join(", ")
+
+    tags_string = tags |> Enum.join(", ")
+
+    "@#{actor_nickname} removed tags: #{tags_string} from users: #{nicknames_string}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "grant",
+          "subject" => %{"nickname" => subject_nickname},
+          "permission" => permission
+        }
+      }) do
+    "@#{actor_nickname} made @#{subject_nickname} #{permission}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "revoke",
+          "subject" => %{"nickname" => subject_nickname},
+          "permission" => permission
+        }
+      }) do
+    "@#{actor_nickname} revoked #{permission} role from @#{subject_nickname}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "relay_follow",
+          "target" => target
+        }
+      }) do
+    "@#{actor_nickname} followed relay: #{target}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "relay_unfollow",
+          "target" => target
+        }
+      }) do
+    "@#{actor_nickname} unfollowed relay: #{target}"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "report_update",
+          "subject" => %{"id" => subject_id, "state" => state, "type" => "report"}
+        }
+      }) do
+    "@#{actor_nickname} updated report ##{subject_id} with '#{state}' state"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "report_response",
+          "subject" => %{"id" => subject_id, "type" => "report"},
+          "text" => text
+        }
+      }) do
+    "@#{actor_nickname} responded with '#{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" => "status_update",
+          "subject" => %{"id" => subject_id, "type" => "status"},
+          "sensitive" => nil,
+          "visibility" => visibility
+        }
+      }) do
+    "@#{actor_nickname} updated status ##{subject_id}, set visibility: '#{visibility}'"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "status_update",
+          "subject" => %{"id" => subject_id, "type" => "status"},
+          "sensitive" => sensitive,
+          "visibility" => nil
+        }
+      }) do
+    "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}'"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "status_update",
+          "subject" => %{"id" => subject_id, "type" => "status"},
+          "sensitive" => sensitive,
+          "visibility" => visibility
+        }
+      }) do
+    "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}', visibility: '#{
+      visibility
+    }'"
+  end
+
+  @spec get_log_entry_message(ModerationLog) :: String.t()
+  def get_log_entry_message(%ModerationLog{
+        data: %{
+          "actor" => %{"nickname" => actor_nickname},
+          "action" => "status_delete",
+          "subject_id" => subject_id
+        }
+      }) do
+    "@#{actor_nickname} deleted status ##{subject_id}"
+  end
+end
index 048ac80198320cee630adf7b1c73f4fa4f7e322b..544b9d7d8b530018120c3f1a9f15af36a223e43a 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   use Pleroma.Web, :controller
   alias Pleroma.Activity
+  alias Pleroma.ModerationLog
   alias Pleroma.User
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.ActivityPub.ActivityPub
@@ -12,6 +13,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.AdminAPI.Config
   alias Pleroma.Web.AdminAPI.ConfigView
+  alias Pleroma.Web.AdminAPI.ModerationLogView
   alias Pleroma.Web.AdminAPI.ReportView
   alias Pleroma.Web.AdminAPI.Search
   alias Pleroma.Web.CommonAPI
@@ -25,35 +27,61 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
 
   action_fallback(:errors)
 
-  def user_delete(conn, %{"nickname" => nickname}) do
-    User.get_cached_by_nickname(nickname)
-    |> User.delete()
+  def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
+    user = User.get_cached_by_nickname(nickname)
+    User.delete(user)
+
+    ModerationLog.insert_log(%{
+      actor: admin,
+      subject: user,
+      action: "delete"
+    })
 
     conn
     |> json(nickname)
   end
 
-  def user_follow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do
+  def user_follow(%{assigns: %{user: admin}} = conn, %{
+        "follower" => follower_nick,
+        "followed" => followed_nick
+      }) do
     with %User{} = follower <- User.get_cached_by_nickname(follower_nick),
          %User{} = followed <- User.get_cached_by_nickname(followed_nick) do
       User.follow(follower, followed)
+
+      ModerationLog.insert_log(%{
+        actor: admin,
+        followed: followed,
+        follower: follower,
+        action: "follow"
+      })
     end
 
     conn
     |> json("ok")
   end
 
-  def user_unfollow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do
+  def user_unfollow(%{assigns: %{user: admin}} = conn, %{
+        "follower" => follower_nick,
+        "followed" => followed_nick
+      }) do
     with %User{} = follower <- User.get_cached_by_nickname(follower_nick),
          %User{} = followed <- User.get_cached_by_nickname(followed_nick) do
       User.unfollow(follower, followed)
+
+      ModerationLog.insert_log(%{
+        actor: admin,
+        followed: followed,
+        follower: follower,
+        action: "unfollow"
+      })
     end
 
     conn
     |> json("ok")
   end
 
-  def users_create(conn, %{"users" => users}) do
+  def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do
     changesets =
       Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} ->
         user_data = %{
@@ -78,10 +106,17 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
           |> Map.values()
           |> Enum.map(fn user ->
             {:ok, user} = User.post_register_action(user)
+
             user
           end)
           |> Enum.map(&AccountView.render("created.json", %{user: &1}))
 
+        ModerationLog.insert_log(%{
+          actor: admin,
+          subjects: Map.values(users),
+          action: "create"
+        })
+
         conn
         |> json(res)
 
@@ -129,23 +164,47 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     end
   end
 
-  def user_toggle_activation(conn, %{"nickname" => nickname}) do
+  def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
     user = User.get_cached_by_nickname(nickname)
 
     {:ok, updated_user} = User.deactivate(user, !user.info.deactivated)
 
+    action = if user.info.deactivated, do: "activate", else: "deactivate"
+
+    ModerationLog.insert_log(%{
+      actor: admin,
+      subject: user,
+      action: action
+    })
+
     conn
     |> json(AccountView.render("show.json", %{user: updated_user}))
   end
 
-  def tag_users(conn, %{"nicknames" => nicknames, "tags" => tags}) do
-    with {:ok, _} <- User.tag(nicknames, tags),
-         do: json_response(conn, :no_content, "")
+  def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do
+    with {:ok, _} <- User.tag(nicknames, tags) do
+      ModerationLog.insert_log(%{
+        actor: admin,
+        nicknames: nicknames,
+        tags: tags,
+        action: "tag"
+      })
+
+      json_response(conn, :no_content, "")
+    end
   end
 
-  def untag_users(conn, %{"nicknames" => nicknames, "tags" => tags}) do
-    with {:ok, _} <- User.untag(nicknames, tags),
-         do: json_response(conn, :no_content, "")
+  def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do
+    with {:ok, _} <- User.untag(nicknames, tags) do
+      ModerationLog.insert_log(%{
+        actor: admin,
+        nicknames: nicknames,
+        tags: tags,
+        action: "untag"
+      })
+
+      json_response(conn, :no_content, "")
+    end
   end
 
   def list_users(conn, params) do
@@ -186,7 +245,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     |> Enum.into(%{}, &{&1, true})
   end
 
-  def right_add(conn, %{"permission_group" => permission_group, "nickname" => nickname})
+  def right_add(%{assigns: %{user: admin}} = conn, %{
+        "permission_group" => permission_group,
+        "nickname" => nickname
+      })
       when permission_group in ["moderator", "admin"] do
     user = User.get_cached_by_nickname(nickname)
 
@@ -201,6 +263,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
       |> Ecto.Changeset.change()
       |> Ecto.Changeset.put_embed(:info, info_cng)
 
+    ModerationLog.insert_log(%{
+      action: "grant",
+      actor: admin,
+      subject: user,
+      permission: permission_group
+    })
+
     {:ok, _user} = User.update_and_set_cache(cng)
 
     json(conn, info)
@@ -221,7 +290,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   end
 
   def right_delete(
-        %{assigns: %{user: %User{:nickname => admin_nickname}}} = conn,
+        %{assigns: %{user: %User{:nickname => admin_nickname} = admin}} = conn,
         %{
           "permission_group" => permission_group,
           "nickname" => nickname
@@ -245,6 +314,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
 
       {:ok, _user} = User.update_and_set_cache(cng)
 
+      ModerationLog.insert_log(%{
+        action: "revoke",
+        actor: admin,
+        subject: user,
+        permission: permission_group
+      })
+
       json(conn, info)
     end
   end
@@ -253,15 +329,33 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     render_error(conn, :not_found, "No such permission_group")
   end
 
-  def set_activation_status(conn, %{"nickname" => nickname, "status" => status}) do
+  def set_activation_status(%{assigns: %{user: admin}} = conn, %{
+        "nickname" => nickname,
+        "status" => status
+      }) do
     with {:ok, status} <- Ecto.Type.cast(:boolean, status),
          %User{} = user <- User.get_cached_by_nickname(nickname),
-         {:ok, _} <- User.deactivate(user, !status),
-         do: json_response(conn, :no_content, "")
+         {:ok, _} <- User.deactivate(user, !status) do
+      action = if(user.info.deactivated, do: "activate", else: "deactivate")
+
+      ModerationLog.insert_log(%{
+        actor: admin,
+        subject: user,
+        action: action
+      })
+
+      json_response(conn, :no_content, "")
+    end
   end
 
-  def relay_follow(conn, %{"relay_url" => target}) do
+  def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do
     with {:ok, _message} <- Relay.follow(target) do
+      ModerationLog.insert_log(%{
+        action: "relay_follow",
+        actor: admin,
+        target: target
+      })
+
       json(conn, target)
     else
       _ ->
@@ -271,8 +365,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     end
   end
 
-  def relay_unfollow(conn, %{"relay_url" => target}) do
+  def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do
     with {:ok, _message} <- Relay.unfollow(target) do
+      ModerationLog.insert_log(%{
+        action: "relay_unfollow",
+        actor: admin,
+        target: target
+      })
+
       json(conn, target)
     else
       _ ->
@@ -363,8 +463,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     end
   end
 
-  def report_update_state(conn, %{"id" => id, "state" => state}) do
+  def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" => state}) do
     with {:ok, report} <- CommonAPI.update_report_state(id, state) do
+      ModerationLog.insert_log(%{
+        action: "report_update",
+        actor: admin,
+        subject: report
+      })
+
       conn
       |> put_view(ReportView)
       |> render("show.json", %{report: report})
@@ -381,6 +487,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
 
       {:ok, activity} = CommonAPI.post(user, params)
 
+      ModerationLog.insert_log(%{
+        action: "report_response",
+        actor: user,
+        subject: activity,
+        text: params["status"]
+      })
+
       conn
       |> put_view(StatusView)
       |> render("status.json", %{activity: activity})
@@ -393,8 +506,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     end
   end
 
-  def status_update(conn, %{"id" => id} = params) do
+  def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do
     with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do
+      {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"])
+
+      ModerationLog.insert_log(%{
+        action: "status_update",
+        actor: admin,
+        subject: activity,
+        sensitive: sensitive,
+        visibility: params["visibility"]
+      })
+
       conn
       |> put_view(StatusView)
       |> render("status.json", %{activity: activity})
@@ -403,10 +526,26 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
 
   def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do
     with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
+      ModerationLog.insert_log(%{
+        action: "status_delete",
+        actor: user,
+        subject_id: id
+      })
+
       json(conn, %{})
     end
   end
 
+  def list_log(conn, params) do
+    {page, page_size} = page_params(params)
+
+    log = ModerationLog.get_all(page, page_size)
+
+    conn
+    |> put_view(ModerationLogView)
+    |> render("index.json", %{log: log})
+  end
+
   def migrate_to_db(conn, _params) do
     Mix.Tasks.Pleroma.Config.run(["migrate_to_db"])
     json(conn, %{})
diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex
new file mode 100644 (file)
index 0000000..b3fc7cf
--- /dev/null
@@ -0,0 +1,26 @@
+# Pleroma: A lightweight social networking server
+# Copyright Â© 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.ModerationLogView do
+  use Pleroma.Web, :view
+
+  alias Pleroma.ModerationLog
+
+  def render("index.json", %{log: log}) do
+    render_many(log, __MODULE__, "show.json", as: :log_entry)
+  end
+
+  def render("show.json", %{log_entry: log_entry}) do
+    time =
+      log_entry.inserted_at
+      |> DateTime.from_naive!("Etc/UTC")
+      |> DateTime.to_unix()
+
+    %{
+      data: log_entry.data,
+      time: time,
+      message: ModerationLog.get_log_entry_message(log_entry)
+    }
+  end
+end
index 61b96aba999d3b2b5aa979ea4ba9c62c078c3e77..6958c7511cd66ca712ceaff0ccd74aad4b5f9181 100644 (file)
@@ -93,8 +93,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
           Activity.t() | nil,
           String.t(),
           Participation.t() | nil
-        ) ::
-          {list(String.t()), list(String.t())}
+        ) :: {list(String.t()), list(String.t())}
 
   def get_to_and_cc(_, _, _, _, %Participation{} = participation) do
     participation = Repo.preload(participation, :recipients)
index fdba0f77f7401fb05d54416c2af782735b2d4130..07e2a4c2d2e0841962e7fbd83dcbb6b82bb584eb 100644 (file)
@@ -37,8 +37,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do
   action_fallback(:errors)
 
   def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do
-    with {_, %User{} = user} <-
-           {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do
+    with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do
       RedirectController.redirector_with_meta(conn, %{user: user})
     end
   end
index 97c5016d5cd3fda01db750b609e0a3eaf7f84013..f800d16fd84f52e9bdf6992e1ac859cf6d7ab599 100644 (file)
@@ -198,6 +198,8 @@ defmodule Pleroma.Web.Router do
     post("/config", AdminAPIController, :config_update)
     get("/config/migrate_to_db", AdminAPIController, :migrate_to_db)
     get("/config/migrate_from_db", AdminAPIController, :migrate_from_db)
+
+    get("/moderation_log", AdminAPIController, :list_log)
   end
 
   scope "/", Pleroma.Web.TwitterAPI do
diff --git a/priv/repo/migrations/20190818124341_create_moderation_log.exs b/priv/repo/migrations/20190818124341_create_moderation_log.exs
new file mode 100644 (file)
index 0000000..cef6636
--- /dev/null
@@ -0,0 +1,11 @@
+defmodule Pleroma.Repo.Migrations.CreateModerationLog do
+  use Ecto.Migration
+
+  def change do
+    create table(:moderation_log) do
+      add(:data, :map)
+
+      timestamps()
+    end
+  end
+end
diff --git a/test/moderation_log_test.exs b/test/moderation_log_test.exs
new file mode 100644 (file)
index 0000000..c787084
--- /dev/null
@@ -0,0 +1,301 @@
+# Pleroma: A lightweight social networking server
+# Copyright Â© 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ModerationLogTest do
+  alias Pleroma.Activity
+  alias Pleroma.ModerationLog
+
+  use Pleroma.DataCase
+
+  import Pleroma.Factory
+
+  describe "user moderation" do
+    setup do
+      admin = insert(:user, info: %{is_admin: true})
+      moderator = insert(:user, info: %{is_moderator: true})
+      subject1 = insert(:user)
+      subject2 = insert(:user)
+
+      [admin: admin, moderator: moderator, subject1: subject1, subject2: subject2]
+    end
+
+    test "logging user deletion by moderator", %{moderator: moderator, subject1: subject1} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          subject: subject1,
+          action: "delete"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} deleted user @#{subject1.nickname}"
+    end
+
+    test "logging user creation by moderator", %{
+      moderator: moderator,
+      subject1: subject1,
+      subject2: subject2
+    } do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          subjects: [subject1, subject2],
+          action: "create"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} created users: @#{subject1.nickname}, @#{subject2.nickname}"
+    end
+
+    test "logging user follow by admin", %{admin: admin, subject1: subject1, subject2: subject2} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: admin,
+          followed: subject1,
+          follower: subject2,
+          action: "follow"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{admin.nickname} made @#{subject2.nickname} follow @#{subject1.nickname}"
+    end
+
+    test "logging user unfollow by admin", %{admin: admin, subject1: subject1, subject2: subject2} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: admin,
+          followed: subject1,
+          follower: subject2,
+          action: "unfollow"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{admin.nickname} made @#{subject2.nickname} unfollow @#{subject1.nickname}"
+    end
+
+    test "logging user tagged by admin", %{admin: admin, subject1: subject1, subject2: subject2} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: admin,
+          nicknames: [subject1.nickname, subject2.nickname],
+          tags: ["foo", "bar"],
+          action: "tag"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      users =
+        [subject1.nickname, subject2.nickname]
+        |> Enum.map(&"@#{&1}")
+        |> Enum.join(", ")
+
+      tags = ["foo", "bar"] |> Enum.join(", ")
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{admin.nickname} added tags: #{tags} to users: #{users}"
+    end
+
+    test "logging user untagged by admin", %{admin: admin, subject1: subject1, subject2: subject2} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: admin,
+          nicknames: [subject1.nickname, subject2.nickname],
+          tags: ["foo", "bar"],
+          action: "untag"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      users =
+        [subject1.nickname, subject2.nickname]
+        |> Enum.map(&"@#{&1}")
+        |> Enum.join(", ")
+
+      tags = ["foo", "bar"] |> Enum.join(", ")
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{admin.nickname} removed tags: #{tags} from users: #{users}"
+    end
+
+    test "logging user grant by moderator", %{moderator: moderator, subject1: subject1} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          subject: subject1,
+          action: "grant",
+          permission: "moderator"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} made @#{subject1.nickname} moderator"
+    end
+
+    test "logging user revoke by moderator", %{moderator: moderator, subject1: subject1} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          subject: subject1,
+          action: "revoke",
+          permission: "moderator"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} revoked moderator role from @#{subject1.nickname}"
+    end
+
+    test "logging relay follow", %{moderator: moderator} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "relay_follow",
+          target: "https://example.org/relay"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} followed relay: https://example.org/relay"
+    end
+
+    test "logging relay unfollow", %{moderator: moderator} do
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "relay_unfollow",
+          target: "https://example.org/relay"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} unfollowed relay: https://example.org/relay"
+    end
+
+    test "logging report update", %{moderator: moderator} do
+      report = %Activity{
+        id: "9m9I1F4p8ftrTP6QTI",
+        data: %{
+          "type" => "Flag",
+          "state" => "resolved"
+        }
+      }
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "report_update",
+          subject: report
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} updated report ##{report.id} with 'resolved' state"
+    end
+
+    test "logging report response", %{moderator: moderator} do
+      report = %Activity{
+        id: "9m9I1F4p8ftrTP6QTI",
+        data: %{
+          "type" => "Note"
+        }
+      }
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "report_response",
+          subject: report,
+          text: "look at this"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} responded with 'look at this' to report ##{report.id}"
+    end
+
+    test "logging status sensitivity update", %{moderator: moderator} do
+      note = insert(:note_activity)
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "status_update",
+          subject: note,
+          sensitive: "true",
+          visibility: nil
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} updated status ##{note.id}, set sensitive: 'true'"
+    end
+
+    test "logging status visibility update", %{moderator: moderator} do
+      note = insert(:note_activity)
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "status_update",
+          subject: note,
+          sensitive: nil,
+          visibility: "private"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} updated status ##{note.id}, set visibility: 'private'"
+    end
+
+    test "logging status sensitivity & visibility update", %{moderator: moderator} do
+      note = insert(:note_activity)
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "status_update",
+          subject: note,
+          sensitive: "true",
+          visibility: "private"
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} updated status ##{note.id}, set sensitive: 'true', visibility: 'private'"
+    end
+
+    test "logging status deletion", %{moderator: moderator} do
+      note = insert(:note_activity)
+
+      {:ok, _} =
+        ModerationLog.insert_log(%{
+          actor: moderator,
+          action: "status_delete",
+          subject_id: note.id
+        })
+
+      log = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log) ==
+               "@#{moderator.nickname} deleted status ##{note.id}"
+    end
+  end
+end
index ab829d6bdb78beff3d24615d21903eb056dab9a4..1afdb6a506e1d18bbf3df3eb22f2e7d54639a469 100644 (file)
@@ -7,6 +7,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
 
   alias Pleroma.Activity
   alias Pleroma.HTML
+  alias Pleroma.ModerationLog
+  alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.CommonAPI
@@ -24,6 +26,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
         |> put_req_header("accept", "application/json")
         |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
 
+      log_entry = Repo.one(ModerationLog)
+
+      assert log_entry.data["subject"]["nickname"] == user.nickname
+      assert log_entry.data["action"] == "delete"
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} deleted user @#{user.nickname}"
+
       assert json_response(conn, 200) == user.nickname
     end
 
@@ -51,6 +61,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
 
       response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type"))
       assert response == ["success", "success"]
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} created users: @lain2, @lain"
     end
 
     test "Cannot create user with exisiting email" do
@@ -218,6 +233,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       follower = User.get_cached_by_id(follower.id)
 
       assert User.following?(follower, user)
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} made @#{follower.nickname} follow @#{user.nickname}"
     end
   end
 
@@ -241,6 +261,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       follower = User.get_cached_by_id(follower.id)
 
       refute User.following?(follower, user)
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} made @#{follower.nickname} unfollow @#{user.nickname}"
     end
   end
 
@@ -261,17 +286,30 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
           }&tags[]=foo&tags[]=bar"
         )
 
-      %{conn: conn, user1: user1, user2: user2, user3: user3}
+      %{conn: conn, admin: admin, user1: user1, user2: user2, user3: user3}
     end
 
     test "it appends specified tags to users with specified nicknames", %{
       conn: conn,
+      admin: admin,
       user1: user1,
       user2: user2
     } do
       assert json_response(conn, :no_content)
       assert User.get_cached_by_id(user1.id).tags == ["x", "foo", "bar"]
       assert User.get_cached_by_id(user2.id).tags == ["y", "foo", "bar"]
+
+      log_entry = Repo.one(ModerationLog)
+
+      users =
+        [user1.nickname, user2.nickname]
+        |> Enum.map(&"@#{&1}")
+        |> Enum.join(", ")
+
+      tags = ["foo", "bar"] |> Enum.join(", ")
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} added tags: #{tags} to users: #{users}"
     end
 
     test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do
@@ -297,17 +335,30 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
           }&tags[]=x&tags[]=z"
         )
 
-      %{conn: conn, user1: user1, user2: user2, user3: user3}
+      %{conn: conn, admin: admin, user1: user1, user2: user2, user3: user3}
     end
 
     test "it removes specified tags from users with specified nicknames", %{
       conn: conn,
+      admin: admin,
       user1: user1,
       user2: user2
     } do
       assert json_response(conn, :no_content)
       assert User.get_cached_by_id(user1.id).tags == []
       assert User.get_cached_by_id(user2.id).tags == ["y"]
+
+      log_entry = Repo.one(ModerationLog)
+
+      users =
+        [user1.nickname, user2.nickname]
+        |> Enum.map(&"@#{&1}")
+        |> Enum.join(", ")
+
+      tags = ["x", "z"] |> Enum.join(", ")
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} removed tags: #{tags} from users: #{users}"
     end
 
     test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do
@@ -345,6 +396,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       assert json_response(conn, 200) == %{
                "is_admin" => true
              }
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} made @#{user.nickname} admin"
     end
 
     test "/:right DELETE, can remove from a permission group" do
@@ -360,6 +416,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       assert json_response(conn, 200) == %{
                "is_admin" => false
              }
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} revoked admin role from @#{user.nickname}"
     end
   end
 
@@ -372,10 +433,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
         |> assign(:user, admin)
         |> put_req_header("accept", "application/json")
 
-      %{conn: conn}
+      %{conn: conn, admin: admin}
     end
 
-    test "deactivates the user", %{conn: conn} do
+    test "deactivates the user", %{conn: conn, admin: admin} do
       user = insert(:user)
 
       conn =
@@ -385,9 +446,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       user = User.get_cached_by_id(user.id)
       assert user.info.deactivated == true
       assert json_response(conn, :no_content)
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} deactivated user @#{user.nickname}"
     end
 
-    test "activates the user", %{conn: conn} do
+    test "activates the user", %{conn: conn, admin: admin} do
       user = insert(:user, info: %{deactivated: true})
 
       conn =
@@ -397,6 +463,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       user = User.get_cached_by_id(user.id)
       assert user.info.deactivated == false
       assert json_response(conn, :no_content)
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} activated user @#{user.nickname}"
     end
 
     test "returns 403 when requested by a non-admin", %{conn: conn} do
@@ -987,6 +1058,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
                "avatar" => User.avatar_url(user) |> MediaProxy.url(),
                "display_name" => HTML.strip_tags(user.name || user.nickname)
              }
+
+    log_entry = Repo.one(ModerationLog)
+
+    assert ModerationLog.get_log_entry_message(log_entry) ==
+             "@#{admin.nickname} deactivated user @#{user.nickname}"
   end
 
   describe "GET /api/pleroma/admin/users/invite_token" do
@@ -1172,25 +1248,35 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
           "status_ids" => [activity.id]
         })
 
-      %{conn: assign(conn, :user, admin), id: report_id}
+      %{conn: assign(conn, :user, admin), id: report_id, admin: admin}
     end
 
-    test "mark report as resolved", %{conn: conn, id: id} do
+    test "mark report as resolved", %{conn: conn, id: id, admin: admin} do
       response =
         conn
         |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "resolved"})
         |> json_response(:ok)
 
       assert response["state"] == "resolved"
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} updated report ##{id} with 'resolved' state"
     end
 
-    test "closes report", %{conn: conn, id: id} do
+    test "closes report", %{conn: conn, id: id, admin: admin} do
       response =
         conn
         |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "closed"})
         |> json_response(:ok)
 
       assert response["state"] == "closed"
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} updated report ##{id} with 'closed' state"
     end
 
     test "returns 400 when state is unknown", %{conn: conn, id: id} do
@@ -1321,14 +1407,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
   end
 
+  #
   describe "POST /api/pleroma/admin/reports/:id/respond" do
     setup %{conn: conn} do
       admin = insert(:user, info: %{is_admin: true})
 
-      %{conn: assign(conn, :user, admin)}
+      %{conn: assign(conn, :user, admin), admin: admin}
     end
 
-    test "returns created dm", %{conn: conn} do
+    test "returns created dm", %{conn: conn, admin: admin} do
       [reporter, target_user] = insert_pair(:user)
       activity = insert(:note_activity, user: target_user)
 
@@ -1351,6 +1438,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       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
@@ -1374,10 +1468,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       admin = insert(:user, info: %{is_admin: true})
       activity = insert(:note_activity)
 
-      %{conn: assign(conn, :user, admin), id: activity.id}
+      %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
     end
 
-    test "toggle sensitive flag", %{conn: conn, id: id} do
+    test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do
       response =
         conn
         |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "true"})
@@ -1385,6 +1479,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
 
       assert response["sensitive"]
 
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} updated status ##{id}, set sensitive: 'true'"
+
       response =
         conn
         |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "false"})
@@ -1393,7 +1492,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       refute response["sensitive"]
     end
 
-    test "change visibility flag", %{conn: conn, id: id} do
+    test "change visibility flag", %{conn: conn, id: id, admin: admin} do
       response =
         conn
         |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "public"})
@@ -1401,6 +1500,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
 
       assert response["visibility"] == "public"
 
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} updated status ##{id}, set visibility: 'public'"
+
       response =
         conn
         |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "private"})
@@ -1430,15 +1534,20 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       admin = insert(:user, info: %{is_admin: true})
       activity = insert(:note_activity)
 
-      %{conn: assign(conn, :user, admin), id: activity.id}
+      %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
     end
 
-    test "deletes status", %{conn: conn, id: id} do
+    test "deletes status", %{conn: conn, id: id, admin: admin} do
       conn
       |> delete("/api/pleroma/admin/statuses/#{id}")
       |> json_response(:ok)
 
       refute Activity.get_by_id(id)
+
+      log_entry = Repo.one(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(log_entry) ==
+               "@#{admin.nickname} deleted status ##{id}"
     end
 
     test "returns error when status is not exist", %{conn: conn} do
@@ -2139,6 +2248,108 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       assert json_response(conn, 200) |> length() == 5
     end
   end
+
+  describe "GET /api/pleroma/admin/moderation_log" do
+    setup %{conn: conn} do
+      admin = insert(:user, info: %{is_admin: true})
+
+      %{conn: assign(conn, :user, admin), admin: admin}
+    end
+
+    test "returns the log", %{conn: conn, admin: admin} do
+      Repo.insert(%ModerationLog{
+        data: %{
+          actor: %{
+            "id" => admin.id,
+            "nickname" => admin.nickname,
+            "type" => "user"
+          },
+          action: "relay_follow",
+          target: "https://example.org/relay"
+        },
+        inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second)
+      })
+
+      Repo.insert(%ModerationLog{
+        data: %{
+          actor: %{
+            "id" => admin.id,
+            "nickname" => admin.nickname,
+            "type" => "user"
+          },
+          action: "relay_unfollow",
+          target: "https://example.org/relay"
+        },
+        inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second)
+      })
+
+      conn = get(conn, "/api/pleroma/admin/moderation_log")
+
+      response = json_response(conn, 200)
+      [first_entry, second_entry] = response
+
+      assert response |> length() == 2
+      assert first_entry["data"]["action"] == "relay_unfollow"
+
+      assert first_entry["message"] ==
+               "@#{admin.nickname} unfollowed relay: https://example.org/relay"
+
+      assert second_entry["data"]["action"] == "relay_follow"
+
+      assert second_entry["message"] ==
+               "@#{admin.nickname} followed relay: https://example.org/relay"
+    end
+
+    test "returns the log with pagination", %{conn: conn, admin: admin} do
+      Repo.insert(%ModerationLog{
+        data: %{
+          actor: %{
+            "id" => admin.id,
+            "nickname" => admin.nickname,
+            "type" => "user"
+          },
+          action: "relay_follow",
+          target: "https://example.org/relay"
+        },
+        inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second)
+      })
+
+      Repo.insert(%ModerationLog{
+        data: %{
+          actor: %{
+            "id" => admin.id,
+            "nickname" => admin.nickname,
+            "type" => "user"
+          },
+          action: "relay_unfollow",
+          target: "https://example.org/relay"
+        },
+        inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second)
+      })
+
+      conn1 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=1")
+
+      response1 = json_response(conn1, 200)
+      [first_entry] = response1
+
+      assert response1 |> length() == 1
+      assert first_entry["data"]["action"] == "relay_unfollow"
+
+      assert first_entry["message"] ==
+               "@#{admin.nickname} unfollowed relay: https://example.org/relay"
+
+      conn2 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=2")
+
+      response2 = json_response(conn2, 200)
+      [second_entry] = response2
+
+      assert response2 |> length() == 1
+      assert second_entry["data"]["action"] == "relay_follow"
+
+      assert second_entry["message"] ==
+               "@#{admin.nickname} followed relay: https://example.org/relay"
+    end
+  end
 end
 
 # Needed for testing