Merge remote-tracking branch 'pleroma/develop' into use-jobs-in-webpush
authorEgor Kislitsyn <egor@kislitsyn.com>
Mon, 8 Apr 2019 09:46:59 +0000 (16:46 +0700)
committerEgor Kislitsyn <egor@kislitsyn.com>
Mon, 8 Apr 2019 09:46:59 +0000 (16:46 +0700)
17 files changed:
config/config.exs
config/test.exs
docs/config.md
lib/pleroma/activity.ex
lib/pleroma/application.ex
lib/pleroma/scheduled_activity.ex [new file with mode: 0644]
lib/pleroma/scheduled_activity_worker.ex [new file with mode: 0644]
lib/pleroma/web/mastodon_api/mastodon_api.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
priv/repo/migrations/20190328053912_create_scheduled_activities.exs [new file with mode: 0644]
test/scheduled_activity_test.exs [new file with mode: 0644]
test/scheduled_activity_worker_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 0a80287ae5b6003d9f7f25afc65cf9e54fb9e16d..7ca5d0c874fbbecd0548e926232dc29975406697 100644 (file)
@@ -362,7 +362,8 @@ config :pleroma_job_queue, :queues,
   federator_incoming: 50,
   federator_outgoing: 50,
   web_push: 50,
-  mailer: 10
+  mailer: 10,
+  scheduled_activities: 10
 
 config :pleroma, :fetch_initial_posts,
   enabled: false,
@@ -393,6 +394,11 @@ config :pleroma, Pleroma.Mailer, adapter: Swoosh.Adapters.Sendmail
 
 config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, path: "/api/pleroma/app_metrics"
 
+config :pleroma, Pleroma.ScheduledActivity,
+  daily_user_limit: 25,
+  total_user_limit: 300,
+  enabled: true
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
 import_config "#{Mix.env()}.exs"
index 6a7b9067eebcd0fa6e42a952adc44623fad8ffb4..894fa8d3d3eb541f5770d54d2cb565ebb2a570dd 100644 (file)
@@ -50,6 +50,11 @@ config :web_push_encryption, :http_client, Pleroma.Web.WebPushHttpClientMock
 
 config :pleroma_job_queue, disabled: true
 
+config :pleroma, Pleroma.ScheduledActivity,
+  daily_user_limit: 2,
+  total_user_limit: 3,
+  enabled: false
+
 try do
   import_config "test.secret.exs"
 rescue
index 6f31195737f65767a6effe0e76638f1e7a7cc56d..357f12e5d6cdff35bc6e51367c6b23b24602b9a2 100644 (file)
@@ -318,6 +318,7 @@ Pleroma has the following queues:
 * `federator_incoming` - Incoming federation
 * `mailer` - Email sender, see [`Pleroma.Mailer`](#pleroma-mailer)
 * `web_push` - Web push notifications
+* `scheduled_activities` - Scheduled activities, see [`Pleroma.ScheduledActivities`](#pleromascheduledactivity)
 
 Example:
 
@@ -413,3 +414,9 @@ 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 (Default: `25`)
+* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`)
+* `enabled`: whether scheduled activities are sent to the job queue to be executed
index bc3f8caba55884ad236708300afce2c7fcff51bc..ab8861b277b7683660e6ca031dd8f69db664522c 100644 (file)
@@ -31,7 +31,7 @@ defmodule Pleroma.Activity do
     field(:data, :map)
     field(:local, :boolean, default: true)
     field(:actor, :string)
-    field(:recipients, {:array, :string})
+    field(:recipients, {:array, :string}, default: [])
     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!
index bd15034e02d79dcb1c4f13e5b3e2d09e78f0b634..eeb415084041cdec1e212942a9d75f9b6e94a38e 100644 (file)
@@ -104,7 +104,8 @@ defmodule Pleroma.Application do
           ],
           id: :cachex_idem
         ),
-        worker(Pleroma.FlakeId, [])
+        worker(Pleroma.FlakeId, []),
+        worker(Pleroma.ScheduledActivityWorker, [])
       ] ++
         hackney_pool_children() ++
         [
diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex
new file mode 100644 (file)
index 0000000..de0e546
--- /dev/null
@@ -0,0 +1,161 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ScheduledActivity do
+  use Ecto.Schema
+
+  alias Pleroma.Config
+  alias Pleroma.Repo
+  alias Pleroma.ScheduledActivity
+  alias Pleroma.User
+  alias Pleroma.Web.CommonAPI.Utils
+
+  import Ecto.Query
+  import Ecto.Changeset
+
+  @min_offset :timer.minutes(5)
+
+  schema "scheduled_activities" do
+    belongs_to(:user, User, type: Pleroma.FlakeId)
+    field(:scheduled_at, :naive_datetime)
+    field(:params, :map)
+
+    timestamps()
+  end
+
+  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
+    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([sa], type(sa.scheduled_at, :date) == type(^scheduled_at, :date))
+    |> select([sa], count(sa.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([sa], count(sa.id))
+    |> Repo.one()
+    |> Kernel.>=(Config.get([ScheduledActivity, :total_user_limit]))
+  end
+
+  def far_enough?(scheduled_at) when is_binary(scheduled_at) do
+    with {:ok, scheduled_at} <- Ecto.Type.cast(:naive_datetime, scheduled_at) do
+      far_enough?(scheduled_at)
+    else
+      _ -> false
+    end
+  end
+
+  def far_enough?(scheduled_at) do
+    now = NaiveDateTime.utc_now()
+    diff = NaiveDateTime.diff(scheduled_at, now, :millisecond)
+    diff > @min_offset
+  end
+
+  def new(%User{} = user, attrs) do
+    %ScheduledActivity{user_id: user.id}
+    |> changeset(attrs)
+  end
+
+  def create(%User{} = user, attrs) do
+    user
+    |> new(attrs)
+    |> Repo.insert()
+  end
+
+  def get(%User{} = user, scheduled_activity_id) do
+    ScheduledActivity
+    |> where(user_id: ^user.id)
+    |> where(id: ^scheduled_activity_id)
+    |> Repo.one()
+  end
+
+  def update(%ScheduledActivity{} = scheduled_activity, attrs) do
+    scheduled_activity
+    |> update_changeset(attrs)
+    |> Repo.update()
+  end
+
+  def delete(%ScheduledActivity{} = scheduled_activity) do
+    scheduled_activity
+    |> Repo.delete()
+  end
+
+  def delete(id) when is_binary(id) or is_integer(id) do
+    ScheduledActivity
+    |> where(id: ^id)
+    |> select([sa], sa)
+    |> Repo.delete_all()
+    |> case do
+      {1, [scheduled_activity]} -> {:ok, scheduled_activity}
+      _ -> :error
+    end
+  end
+
+  def for_user_query(%User{} = user) do
+    ScheduledActivity
+    |> where(user_id: ^user.id)
+  end
+
+  def due_activities(offset \\ 0) do
+    naive_datetime =
+      NaiveDateTime.utc_now()
+      |> NaiveDateTime.add(offset, :millisecond)
+
+    ScheduledActivity
+    |> where([sa], sa.scheduled_at < ^naive_datetime)
+    |> Repo.all()
+  end
+end
diff --git a/lib/pleroma/scheduled_activity_worker.ex b/lib/pleroma/scheduled_activity_worker.ex
new file mode 100644 (file)
index 0000000..65b3862
--- /dev/null
@@ -0,0 +1,58 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ScheduledActivityWorker do
+  @moduledoc """
+  Sends scheduled activities to the job queue.
+  """
+
+  alias Pleroma.Config
+  alias Pleroma.ScheduledActivity
+  alias Pleroma.User
+  alias Pleroma.Web.CommonAPI
+  use GenServer
+  require Logger
+
+  @schedule_interval :timer.minutes(1)
+
+  def start_link do
+    GenServer.start_link(__MODULE__, nil)
+  end
+
+  def init(_) do
+    if Config.get([ScheduledActivity, :enabled]) do
+      schedule_next()
+      {:ok, nil}
+    else
+      :ignore
+    end
+  end
+
+  def perform(:execute, scheduled_activity_id) do
+    try do
+      {:ok, scheduled_activity} = ScheduledActivity.delete(scheduled_activity_id)
+      %User{} = user = User.get_cached_by_id(scheduled_activity.user_id)
+      {:ok, _result} = CommonAPI.post(user, scheduled_activity.params)
+    rescue
+      error ->
+        Logger.error(
+          "#{__MODULE__} Couldn't create a status from the scheduled activity: #{inspect(error)}"
+        )
+    end
+  end
+
+  def handle_info(:perform, state) do
+    ScheduledActivity.due_activities(@schedule_interval)
+    |> Enum.each(fn scheduled_activity ->
+      PleromaJobQueue.enqueue(:scheduled_activities, __MODULE__, [:execute, scheduled_activity.id])
+    end)
+
+    schedule_next()
+    {:noreply, state}
+  end
+
+  defp schedule_next do
+    Process.send_after(self(), :perform, @schedule_interval)
+  end
+end
index 08ea5f967c00652e60b8fdd16be254d87e473fbe..382f07e6b1f930536ed3c3f7dc0d6c22ec473725 100644 (file)
@@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
   alias Pleroma.Activity
   alias Pleroma.Notification
   alias Pleroma.Pagination
+  alias Pleroma.ScheduledActivity
   alias Pleroma.User
 
   def get_followers(user, params \\ %{}) do
@@ -28,6 +29,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
     |> Pagination.fetch_paginated(params)
   end
 
+  def get_scheduled_activities(user, params \\ %{}) do
+    user
+    |> ScheduledActivity.for_user_query()
+    |> Pagination.fetch_paginated(params)
+  end
+
   defp cast_params(params) do
     param_types = %{
       exclude_types: {:array, :string}
index bcc79b08a03152bf60670dbd5a0834bc7f62f1c4..fc8a2458c354e5bddabd9ac7a63dde44ebcd2e0d 100644 (file)
@@ -5,12 +5,14 @@
 defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
   use Pleroma.Web, :controller
 
+  alias Ecto.Changeset
   alias Pleroma.Activity
   alias Pleroma.Config
   alias Pleroma.Filter
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.ScheduledActivity
   alias Pleroma.Stats
   alias Pleroma.User
   alias Pleroma.Web
@@ -25,6 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
   alias Pleroma.Web.MastodonAPI.MastodonView
   alias Pleroma.Web.MastodonAPI.NotificationView
   alias Pleroma.Web.MastodonAPI.ReportView
+  alias Pleroma.Web.MastodonAPI.ScheduledActivityView
   alias Pleroma.Web.MastodonAPI.StatusView
   alias Pleroma.Web.MediaProxy
   alias Pleroma.Web.OAuth.App
@@ -364,6 +367,55 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
     end
   end
 
+  def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do
+    with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do
+      conn
+      |> add_link_headers(:scheduled_statuses, scheduled_activities)
+      |> put_view(ScheduledActivityView)
+      |> render("index.json", %{scheduled_activities: scheduled_activities})
+    end
+  end
+
+  def show_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do
+    with %ScheduledActivity{} = scheduled_activity <-
+           ScheduledActivity.get(user, scheduled_activity_id) do
+      conn
+      |> put_view(ScheduledActivityView)
+      |> render("show.json", %{scheduled_activity: scheduled_activity})
+    else
+      _ -> {:error, :not_found}
+    end
+  end
+
+  def update_scheduled_status(
+        %{assigns: %{user: user}} = conn,
+        %{"id" => 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 %ScheduledActivity{} = scheduled_activity <-
+           ScheduledActivity.get(user, scheduled_activity_id),
+         {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do
+      conn
+      |> put_view(ScheduledActivityView)
+      |> render("show.json", %{scheduled_activity: scheduled_activity})
+    else
+      nil -> {:error, :not_found}
+      error -> error
+    end
+  end
+
   def post_status(conn, %{"status" => "", "media_ids" => media_ids} = params)
       when length(media_ids) > 0 do
     params =
@@ -384,12 +436,27 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
         _ -> Ecto.UUID.generate()
       end
 
-    {:ok, activity} =
-      Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> CommonAPI.post(user, params) end)
+    scheduled_at = params["scheduled_at"]
 
-    conn
-    |> put_view(StatusView)
-    |> try_render("status.json", %{activity: activity, for: user, as: :activity})
+    if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do
+      with {:ok, scheduled_activity} <-
+             ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do
+        conn
+        |> put_view(ScheduledActivityView)
+        |> render("show.json", %{scheduled_activity: scheduled_activity})
+      end
+    else
+      params = Map.drop(params, ["scheduled_at"])
+
+      {:ok, activity} =
+        Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ ->
+          CommonAPI.post(user, params)
+        end)
+
+      conn
+      |> put_view(StatusView)
+      |> try_render("status.json", %{activity: activity, for: user, as: :activity})
+    end
   end
 
   def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
@@ -1406,6 +1473,23 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
 
   # fallback action
   #
+  def errors(conn, {:error, %Changeset{} = changeset}) do
+    error_message =
+      changeset
+      |> Changeset.traverse_errors(fn {message, _opt} -> message end)
+      |> Enum.map_join(", ", fn {_k, v} -> v end)
+
+    conn
+    |> put_status(422)
+    |> json(%{error: error_message})
+  end
+
+  def errors(conn, {:error, :not_found}) do
+    conn
+    |> put_status(404)
+    |> json(%{error: "Record not found"})
+  end
+
   def errors(conn, _) do
     conn
     |> put_status(500)
diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex
new file mode 100644 (file)
index 0000000..0aae15a
--- /dev/null
@@ -0,0 +1,57 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do
+  use Pleroma.Web, :view
+
+  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")
+  end
+
+  def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do
+    %{
+      id: to_string(scheduled_activity.id),
+      scheduled_at: CommonAPI.Utils.to_masto_date(scheduled_activity.scheduled_at),
+      params: status_params(scheduled_activity.params)
+    }
+    |> with_media_attachments(scheduled_activity)
+  end
+
+  defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do
+    try do
+      attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment)
+      Map.put(data, :media_attachments, attachments)
+    rescue
+      _ -> data
+    end
+  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 1c752e44c3675b38e6e46b10d776d6011311eafc..3b5ac6fdded2e41aab49a95bd6ee646f88bb18d5 100644 (file)
@@ -244,6 +244,9 @@ defmodule Pleroma.Web.Router do
       get("/notifications", MastodonAPIController, :notifications)
       get("/notifications/:id", MastodonAPIController, :get_notification)
 
+      get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses)
+      get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status)
+
       get("/lists", MastodonAPIController, :get_lists)
       get("/lists/:id", MastodonAPIController, :get_list)
       get("/lists/:id/accounts", MastodonAPIController, :list_accounts)
@@ -278,6 +281,9 @@ defmodule Pleroma.Web.Router do
       post("/statuses/:id/mute", MastodonAPIController, :mute_conversation)
       post("/statuses/:id/unmute", MastodonAPIController, :unmute_conversation)
 
+      put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status)
+      delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status)
+
       post("/media", MastodonAPIController, :upload)
       put("/media/:id", MastodonAPIController, :update_media)
 
diff --git a/priv/repo/migrations/20190328053912_create_scheduled_activities.exs b/priv/repo/migrations/20190328053912_create_scheduled_activities.exs
new file mode 100644 (file)
index 0000000..dd737e2
--- /dev/null
@@ -0,0 +1,16 @@
+defmodule Pleroma.Repo.Migrations.CreateScheduledActivities do
+  use Ecto.Migration
+
+  def change do
+    create table(:scheduled_activities) do
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+      add(:scheduled_at, :naive_datetime, null: false)
+      add(:params, :map, null: false)
+
+      timestamps()
+    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..edc7cc3
--- /dev/null
@@ -0,0 +1,64 @@
+# 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.DataCase
+  alias Pleroma.ScheduledActivity
+  import Pleroma.Factory
+
+  setup context do
+    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(36), :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
+  end
+end
diff --git a/test/scheduled_activity_worker_test.exs b/test/scheduled_activity_worker_test.exs
new file mode 100644 (file)
index 0000000..b9c91dd
--- /dev/null
@@ -0,0 +1,19 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ScheduledActivityWorkerTest do
+  use Pleroma.DataCase
+  alias Pleroma.ScheduledActivity
+  import Pleroma.Factory
+
+  test "creates a status from the scheduled activity" do
+    user = insert(:user)
+    scheduled_activity = insert(:scheduled_activity, user: user, params: %{status: "hi"})
+    Pleroma.ScheduledActivityWorker.perform(:execute, scheduled_activity.id)
+
+    refute Repo.get(ScheduledActivity, scheduled_activity.id)
+    activity = Repo.all(Pleroma.Activity) |> Enum.find(&(&1.actor == user.ap_id))
+    assert activity.data["object"]["content"] == "hi"
+  end
+end
index b37bc2c07526e5929319b21fc2f6941220878770..608f8d46b0554ef7d461f8789026aa9825c6a363 100644 (file)
@@ -267,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 438e9507d453d6420b011f7b9b59031d282a10b9..cd01116e29d228fd43e156678200384f36754c79 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
   alias Pleroma.Notification
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.ScheduledActivity
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.CommonAPI
@@ -2407,4 +2408,214 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
       assert redirected_to(conn) == "/web/getting-started"
     end
   end
+
+  describe "scheduled activities" do
+    test "creates a scheduled activity", %{conn: conn} do
+      user = insert(:user)
+      scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "scheduled",
+          "scheduled_at" => scheduled_at
+        })
+
+      assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200)
+      assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(scheduled_at)
+      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)
+
+      scheduled_at =
+        NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond)
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "not scheduled",
+          "scheduled_at" => scheduled_at
+        })
+
+      assert %{"content" => "not scheduled"} = json_response(conn, 200)
+      assert [] == Repo.all(ScheduledActivity)
+    end
+
+    test "returns error when daily user limit is exceeded", %{conn: conn} 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)
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today})
+
+      assert %{"error" => "daily limit exceeded"} == json_response(conn, 422)
+    end
+
+    test "returns error when total user limit is exceeded", %{conn: conn} 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(36), :millisecond)
+        |> NaiveDateTime.to_iso8601()
+
+      attrs = %{params: %{}, scheduled_at: today}
+      {:ok, _} = ScheduledActivity.create(user, attrs)
+      {:ok, _} = ScheduledActivity.create(user, attrs)
+      {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow})
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow})
+
+      assert %{"error" => "total limit exceeded"} == json_response(conn, 422)
+    end
+
+    test "shows scheduled activities", %{conn: conn} do
+      user = insert(:user)
+      scheduled_activity_id1 = insert(:scheduled_activity, user: user).id |> to_string()
+      scheduled_activity_id2 = insert(:scheduled_activity, user: user).id |> to_string()
+      scheduled_activity_id3 = insert(:scheduled_activity, user: user).id |> to_string()
+      scheduled_activity_id4 = insert(:scheduled_activity, user: user).id |> to_string()
+
+      conn =
+        conn
+        |> assign(:user, user)
+
+      # min_id
+      conn_res =
+        conn
+        |> get("/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}")
+
+      result = json_response(conn_res, 200)
+      assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
+
+      # since_id
+      conn_res =
+        conn
+        |> get("/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}")
+
+      result = json_response(conn_res, 200)
+      assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result
+
+      # max_id
+      conn_res =
+        conn
+        |> get("/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}")
+
+      result = json_response(conn_res, 200)
+      assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
+    end
+
+    test "shows a scheduled activity", %{conn: conn} do
+      user = insert(:user)
+      scheduled_activity = insert(:scheduled_activity, user: user)
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> get("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
+
+      assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200)
+      assert scheduled_activity_id == scheduled_activity.id |> to_string()
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> get("/api/v1/scheduled_statuses/404")
+
+      assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    end
+
+    test "updates a scheduled activity", %{conn: conn} do
+      user = insert(:user)
+      scheduled_activity = insert(:scheduled_activity, user: user)
+
+      new_scheduled_at =
+        NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond)
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
+          scheduled_at: new_scheduled_at
+        })
+
+      assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200)
+      assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at)
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
+
+      assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    end
+
+    test "deletes a scheduled activity", %{conn: conn} do
+      user = insert(:user)
+      scheduled_activity = insert(:scheduled_activity, user: user)
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
+
+      assert %{} = json_response(res_conn, 200)
+      assert nil == Repo.get(ScheduledActivity, scheduled_activity.id)
+
+      res_conn =
+        conn
+        |> assign(:user, user)
+        |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
+
+      assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    end
+  end
 end
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..ecbb855
--- /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: [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