Added limits and media attachments for scheduled activities.
authoreugenijm <eugenijm@protonmail.com>
Mon, 1 Apr 2019 22:31:01 +0000 (01:31 +0300)
committereugenijm <eugenijm@protonmail.com>
Sat, 6 Apr 2019 20:55:58 +0000 (23:55 +0300)
config/config.exs
docs/config.md
lib/pleroma/object.ex
lib/pleroma/scheduled_activity.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex
priv/repo/migrations/20190328053912_create_scheduled_activities.exs
test/scheduled_activity_test.exs [new file with mode: 0644]
test/support/factory.ex
test/web/mastodon_api/mastodon_api_controller_test.exs
test/web/mastodon_api/scheduled_activity_view_test.exs [new file with mode: 0644]

index 61e799f333557aca200e0b2b9c9c960b8623ec37..79cef87e6bec6c6af7e0ad5ade1d2585e7e93030 100644 (file)
@@ -367,6 +367,10 @@ config :pleroma, :fetch_initial_posts,
   enabled: false,
   pages: 5
 
+config :pleroma, Pleroma.ScheduledActivity,
+  daily_user_limit: 25,
+  total_user_limit: 100
+
 config :auto_linker,
   opts: [
     scheme: true,
index 06d6fd757de0bb704a9bec6d06c7c0b566874b43..df21beff36402836a69d4cefdc2ca6efe724aa8c 100644 (file)
@@ -218,14 +218,14 @@ This section is used to configure Pleroma-FE, unless ``:managed_config`` in ``:i
   - `port`
 * `url` - a list containing the configuration for generating urls, accepts
   - `host` - the host without the scheme and a post (e.g `example.com`, not `https://example.com:2020`)
-  - `scheme` - e.g `http`, `https` 
+  - `scheme` - e.g `http`, `https`
   - `port`
   - `path`
 
 
 **Important note**: if you modify anything inside these lists, default `config.exs` values will be overwritten, which may result in breakage, to make sure this does not happen please copy the default value for the list from `config.exs` and modify/add only what you need
 
-Example: 
+Example:
 ```elixir
 config :pleroma, Pleroma.Web.Endpoint,
   url: [host: "example.com", port: 2020, scheme: "https"],
@@ -412,3 +412,8 @@ Pleroma account will be created with the same name as the LDAP user name.
 
 * `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator
 * `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication
+
+##  Pleroma.ScheduledActivity
+
+* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day
+* `total_user_limit`: the number of scheduled activities a user is allowed to create in total
index 013d6215710ec42858ab18ed3be69e6775e405ed..786d6296cfcc24a3964e943003b245ace3b77667 100644 (file)
@@ -184,4 +184,12 @@ defmodule Pleroma.Object do
       _ -> {:error, "Not found"}
     end
   end
+
+  def enforce_user_objects(user, object_ids) do
+    Object
+    |> where([o], fragment("?->>'actor' = ?", o.data, ^user.ap_id))
+    |> where([o], o.id in ^object_ids)
+    |> select([o], o.id)
+    |> Repo.all()
+  end
 end
index 9fdc1399087ce43e7529014d5c6f147155029799..723eb6dc3f65b4b349a80819cfd7fcfa05668f94 100644 (file)
@@ -5,9 +5,12 @@
 defmodule Pleroma.ScheduledActivity do
   use Ecto.Schema
 
+  alias Pleroma.Config
+  alias Pleroma.Object
   alias Pleroma.Repo
   alias Pleroma.ScheduledActivity
   alias Pleroma.User
+  alias Pleroma.Web.CommonAPI.Utils
 
   import Ecto.Query
   import Ecto.Changeset
@@ -25,11 +28,69 @@ defmodule Pleroma.ScheduledActivity do
   def changeset(%ScheduledActivity{} = scheduled_activity, attrs) do
     scheduled_activity
     |> cast(attrs, [:scheduled_at, :params])
+    |> validate_required([:scheduled_at, :params])
+    |> validate_scheduled_at()
+    |> with_media_attachments()
   end
 
+  defp with_media_attachments(
+         %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset
+       )
+       when is_list(media_ids) do
+    user = User.get_cached_by_id(changeset.data.user_id)
+    media_ids = Object.enforce_user_objects(user, media_ids) |> Enum.map(&to_string(&1))
+    media_attachments = Utils.attachments_from_ids(%{"media_ids" => media_ids})
+
+    params =
+      params
+      |> Map.put("media_attachments", media_attachments)
+      |> Map.put("media_ids", media_ids)
+
+    put_change(changeset, :params, params)
+  end
+
+  defp with_media_attachments(changeset), do: changeset
+
   def update_changeset(%ScheduledActivity{} = scheduled_activity, attrs) do
     scheduled_activity
     |> cast(attrs, [:scheduled_at])
+    |> validate_required([:scheduled_at])
+    |> validate_scheduled_at()
+  end
+
+  def validate_scheduled_at(changeset) do
+    validate_change(changeset, :scheduled_at, fn _, scheduled_at ->
+      cond do
+        not far_enough?(scheduled_at) ->
+          [scheduled_at: "must be at least 5 minutes from now"]
+
+        exceeds_daily_user_limit?(changeset.data.user_id, scheduled_at) ->
+          [scheduled_at: "daily limit exceeded"]
+
+        exceeds_total_user_limit?(changeset.data.user_id) ->
+          [scheduled_at: "total limit exceeded"]
+
+        true ->
+          []
+      end
+    end)
+  end
+
+  def exceeds_daily_user_limit?(user_id, scheduled_at) do
+    ScheduledActivity
+    |> where(user_id: ^user_id)
+    |> where([s], type(s.scheduled_at, :date) == type(^scheduled_at, :date))
+    |> select([u], count(u.id))
+    |> Repo.one()
+    |> Kernel.>=(Config.get([ScheduledActivity, :daily_user_limit]))
+  end
+
+  def exceeds_total_user_limit?(user_id) do
+    ScheduledActivity
+    |> where(user_id: ^user_id)
+    |> select([u], count(u.id))
+    |> Repo.one()
+    |> Kernel.>=(Config.get([ScheduledActivity, :total_user_limit]))
   end
 
   def far_enough?(scheduled_at) when is_binary(scheduled_at) do
@@ -64,23 +125,15 @@ defmodule Pleroma.ScheduledActivity do
     |> Repo.one()
   end
 
-  def update(%User{} = user, scheduled_activity_id, attrs) do
-    with %ScheduledActivity{} = scheduled_activity <- get(user, scheduled_activity_id) do
-      scheduled_activity
-      |> update_changeset(attrs)
-      |> Repo.update()
-    else
-      nil -> {:error, :not_found}
-    end
+  def update(scheduled_activity, attrs) do
+    scheduled_activity
+    |> update_changeset(attrs)
+    |> Repo.update()
   end
 
-  def delete(%User{} = user, scheduled_activity_id) do
-    with %ScheduledActivity{} = scheduled_activity <- get(user, scheduled_activity_id) do
-      scheduled_activity
-      |> Repo.delete()
-    else
-      nil -> {:error, :not_found}
-    end
+  def delete(scheduled_activity) do
+    scheduled_activity
+    |> Repo.delete()
   end
 
   def for_user_query(%User{} = user) do
index 863fc395410f93e3e21bd0b905971fd4a63c4b81..6cb5df378be70e0eb2d1a9b1b08aca54127c7162 100644 (file)
@@ -390,18 +390,28 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
         %{assigns: %{user: user}} = conn,
         %{"id" => scheduled_activity_id} = params
       ) do
-    with {:ok, scheduled_activity} <-
-           ScheduledActivity.update(user, scheduled_activity_id, params) do
+    with %ScheduledActivity{} = scheduled_activity <-
+           ScheduledActivity.get(user, scheduled_activity_id),
+         {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do
       conn
       |> put_view(ScheduledActivityView)
       |> render("show.json", %{scheduled_activity: scheduled_activity})
+    else
+      nil -> {:error, :not_found}
+      error -> error
     end
   end
 
   def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do
-    with {:ok, %ScheduledActivity{}} <- ScheduledActivity.delete(user, scheduled_activity_id) do
+    with %ScheduledActivity{} = scheduled_activity <-
+           ScheduledActivity.get(user, scheduled_activity_id),
+         {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do
       conn
-      |> json(%{})
+      |> put_view(ScheduledActivityView)
+      |> render("show.json", %{scheduled_activity: scheduled_activity})
+    else
+      nil -> {:error, :not_found}
+      error -> error
     end
   end
 
index 87aa3729e453c5a5da91b0b24caf95bfa8b34052..1ebff7aba5ff46d112812c68d0d79637c246c30d 100644 (file)
@@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do
   alias Pleroma.ScheduledActivity
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.MastodonAPI.ScheduledActivityView
+  alias Pleroma.Web.MastodonAPI.StatusView
 
   def render("index.json", %{scheduled_activities: scheduled_activities}) do
     render_many(scheduled_activities, ScheduledActivityView, "show.json")
@@ -17,7 +18,36 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do
     %{
       id: scheduled_activity.id |> to_string,
       scheduled_at: scheduled_activity.scheduled_at |> CommonAPI.Utils.to_masto_date(),
-      params: scheduled_activity.params
+      params: status_params(scheduled_activity.params)
     }
+    |> with_media_attachments(scheduled_activity)
+  end
+
+  defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do
+    attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment)
+    Map.put(data, :media_attachments, attachments)
+  end
+
+  defp with_media_attachments(data, _), do: data
+
+  defp status_params(params) do
+    data = %{
+      text: params["status"],
+      sensitive: params["sensitive"],
+      spoiler_text: params["spoiler_text"],
+      visibility: params["visibility"],
+      scheduled_at: params["scheduled_at"],
+      poll: params["poll"],
+      in_reply_to_id: params["in_reply_to_id"]
+    }
+
+    data =
+      if media_ids = params["media_ids"] do
+        Map.put(data, :media_ids, media_ids)
+      else
+        data
+      end
+
+    data
   end
 end
index dc2436dce1184f96ac7c2f6ad851a2f612c87a2a..dd737e25ab9553e48da39fc355ba459137ff1db0 100644 (file)
@@ -11,5 +11,6 @@ defmodule Pleroma.Repo.Migrations.CreateScheduledActivities do
     end
 
     create(index(:scheduled_activities, [:scheduled_at]))
+    create(index(:scheduled_activities, [:user_id]))
   end
 end
diff --git a/test/scheduled_activity_test.exs b/test/scheduled_activity_test.exs
new file mode 100644 (file)
index 0000000..c49c65c
--- /dev/null
@@ -0,0 +1,93 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ScheduledActivityTest do
+  use Pleroma.DataCase
+  alias Pleroma.Config
+  alias Pleroma.DataCase
+  alias Pleroma.ScheduledActivity
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  import Pleroma.Factory
+
+  setup context do
+    Config.put([ScheduledActivity, :daily_user_limit], 2)
+    Config.put([ScheduledActivity, :total_user_limit], 3)
+    DataCase.ensure_local_uploader(context)
+  end
+
+  describe "creation" do
+    test "when daily user limit is exceeded" do
+      user = insert(:user)
+
+      today =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(:timer.minutes(6), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      attrs = %{params: %{}, scheduled_at: today}
+      {:ok, _} = ScheduledActivity.create(user, attrs)
+      {:ok, _} = ScheduledActivity.create(user, attrs)
+      {:error, changeset} = ScheduledActivity.create(user, attrs)
+      assert changeset.errors == [scheduled_at: {"daily limit exceeded", []}]
+    end
+
+    test "when total user limit is exceeded" do
+      user = insert(:user)
+
+      today =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(:timer.minutes(6), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      tomorrow =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(:timer.hours(24), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: today})
+      {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: today})
+      {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow})
+      {:error, changeset} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow})
+      assert changeset.errors == [scheduled_at: {"total limit exceeded", []}]
+    end
+
+    test "when scheduled_at is earlier than 5 minute from now" do
+      user = insert(:user)
+
+      scheduled_at =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(:timer.minutes(4), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      attrs = %{params: %{}, scheduled_at: scheduled_at}
+      {:error, changeset} = ScheduledActivity.create(user, attrs)
+      assert changeset.errors == [scheduled_at: {"must be at least 5 minutes from now", []}]
+    end
+
+    test "excludes attachments belonging to another user" do
+      user = insert(:user)
+      another_user = insert(:user)
+
+      scheduled_at =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(:timer.minutes(10), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      {:ok, user_upload} = ActivityPub.upload(file, actor: user.ap_id)
+      {:ok, another_user_upload} = ActivityPub.upload(file, actor: another_user.ap_id)
+
+      media_ids = [user_upload.id, another_user_upload.id]
+      attrs = %{params: %{"media_ids" => media_ids}, scheduled_at: scheduled_at}
+      {:ok, scheduled_activity} = ScheduledActivity.create(user, attrs)
+      assert to_string(user_upload.id) in scheduled_activity.params["media_ids"]
+      refute to_string(another_user_upload.id) in scheduled_activity.params["media_ids"]
+    end
+  end
+end
index 667f59e8cd321c05ea2598cf746dbe82fd20705a..608f8d46b0554ef7d461f8789026aa9825c6a363 100644 (file)
@@ -23,14 +23,6 @@ defmodule Pleroma.Factory do
     }
   end
 
-  def scheduled_activity_factory do
-    %Pleroma.ScheduledActivity{
-      user: build(:user),
-      scheduled_at: NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(60), :millisecond),
-      params: build(:note) |> Map.from_struct() |> Map.get(:data)
-    }
-  end
-
   def note_factory(attrs \\ %{}) do
     text = sequence(:text, &"This is :moominmamma: note #{&1}")
 
@@ -275,4 +267,12 @@ defmodule Pleroma.Factory do
       user: build(:user)
     }
   end
+
+  def scheduled_activity_factory do
+    %Pleroma.ScheduledActivity{
+      user: build(:user),
+      scheduled_at: NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(60), :millisecond),
+      params: build(:note) |> Map.from_struct() |> Map.get(:data)
+    }
+  end
 end
index 0ec66ab73aeab859b53984a1a5c2b36d9473f34a..ae237569680ab73a63ee74f8a1ca9514cbf980f4 100644 (file)
@@ -2427,6 +2427,31 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
       assert [] == Repo.all(Activity)
     end
 
+    test "creates a scheduled activity with a media attachment", %{conn: conn} do
+      user = insert(:user)
+      scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+
+      file = %Plug.Upload{
+        content_type: "image/jpg",
+        path: Path.absname("test/fixtures/image.jpg"),
+        filename: "an_image.jpg"
+      }
+
+      {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id)
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "media_ids" => [to_string(upload.id)],
+          "status" => "scheduled",
+          "scheduled_at" => scheduled_at
+        })
+
+      assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200)
+      assert %{"type" => "image"} = media_attachment
+    end
+
     test "skips the scheduling and creates the activity if scheduled_at is earlier than 5 minutes from now",
          %{conn: conn} do
       user = insert(:user)
diff --git a/test/web/mastodon_api/scheduled_activity_view_test.exs b/test/web/mastodon_api/scheduled_activity_view_test.exs
new file mode 100644 (file)
index 0000000..26747a0
--- /dev/null
@@ -0,0 +1,68 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do
+  use Pleroma.DataCase
+  alias Pleroma.ScheduledActivity
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.CommonAPI.Utils
+  alias Pleroma.Web.MastodonAPI.ScheduledActivityView
+  alias Pleroma.Web.MastodonAPI.StatusView
+  import Pleroma.Factory
+
+  test "A scheduled activity with a media attachment" do
+    user = insert(:user)
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "hi"})
+
+    scheduled_at =
+      NaiveDateTime.utc_now()
+      |> NaiveDateTime.add(:timer.minutes(10), :millisecond)
+      |> NaiveDateTime.to_iso8601()
+
+    file = %Plug.Upload{
+      content_type: "image/jpg",
+      path: Path.absname("test/fixtures/image.jpg"),
+      filename: "an_image.jpg"
+    }
+
+    {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id)
+
+    attrs = %{
+      params: %{
+        "media_ids" => [upload.id],
+        "status" => "hi",
+        "sensitive" => true,
+        "spoiler_text" => "spoiler",
+        "visibility" => "unlisted",
+        "in_reply_to_id" => to_string(activity.id)
+      },
+      scheduled_at: scheduled_at
+    }
+
+    {:ok, scheduled_activity} = ScheduledActivity.create(user, attrs)
+    result = ScheduledActivityView.render("show.json", %{scheduled_activity: scheduled_activity})
+
+    expected = %{
+      id: to_string(scheduled_activity.id),
+      media_attachments:
+        %{"media_ids" => [upload.id]}
+        |> Utils.attachments_from_ids()
+        |> Enum.map(&StatusView.render("attachment.json", %{attachment: &1})),
+      params: %{
+        in_reply_to_id: to_string(activity.id),
+        media_ids: [to_string(upload.id)],
+        poll: nil,
+        scheduled_at: nil,
+        sensitive: true,
+        spoiler_text: "spoiler",
+        text: "hi",
+        visibility: "unlisted"
+      },
+      scheduled_at: Utils.to_masto_date(scheduled_activity.scheduled_at)
+    }
+
+    assert expected == result
+  end
+end