diff --git a/config/config.exs b/config/config.exs
index d68edafcb..779d12886 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -8,6 +8,10 @@
 # General application configuration
 config :pleroma, ecto_repos: [Pleroma.Repo]
 
+config :pleroma, Pleroma.Repo,
+  types: Pleroma.PostgresTypes,
+  telemetry_event: [Pleroma.Repo.Instrumenter]
+
 config :pleroma, Pleroma.Captcha,
   enabled: false,
   seconds_valid: 60,
@@ -87,6 +91,7 @@
 
 # Configures the endpoint
 config :pleroma, Pleroma.Web.Endpoint,
+  instrumenters: [Pleroma.Web.Endpoint.Instrumenter],
   url: [host: "localhost"],
   http: [
     dispatch: [
@@ -118,6 +123,11 @@
   format: "$metadata[$level] $message",
   metadata: [:request_id]
 
+config :quack,
+  level: :warn,
+  meta: [:all],
+  webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE"
+
 config :mime, :types, %{
   "application/xml" => ["xml"],
   "application/xrd+xml" => ["xrd+xml"],
@@ -352,7 +362,8 @@
   federator_incoming: 50,
   federator_outgoing: 50,
   mailer: 10,
-  transmogrifier: 20
+  transmogrifier: 20,
+  scheduled_activities: 10
 
 config :pleroma, :fetch_initial_posts,
   enabled: false,
@@ -381,6 +392,13 @@
 
 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"
diff --git a/config/test.exs b/config/test.exs
index 6a7b9067e..894fa8d3d 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -50,6 +50,11 @@
 
 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
diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md
index 53b68ffd4..86cacebb1 100644
--- a/docs/api/admin_api.md
+++ b/docs/api/admin_api.md
@@ -58,6 +58,26 @@ Authentication is required and the user must be an admin.
   - `password`
 - Response: User’s nickname
 
+## `/api/pleroma/admin/user/follow`
+### Make a user follow another user
+
+- Methods: `POST`
+- Params:
+ - `follower`: The nickname of the follower
+ - `followed`: The nickname of the followed
+- Response:
+ - "ok"
+
+## `/api/pleroma/admin/user/unfollow`
+### Make a user unfollow another user
+
+- Methods: `POST`
+- Params:
+ - `follower`: The nickname of the follower
+ - `followed`: The nickname of the followed
+- Response:
+ - "ok"
+
 ## `/api/pleroma/admin/users/:nickname/toggle_activation`
 
 ### Toggle user activation
diff --git a/docs/api/prometheus.md b/docs/api/prometheus.md
new file mode 100644
index 000000000..19c564e3c
--- /dev/null
+++ b/docs/api/prometheus.md
@@ -0,0 +1,22 @@
+# Prometheus Metrics
+
+Pleroma includes support for exporting metrics via the [prometheus_ex](https://github.com/deadtrickster/prometheus.ex) library.
+
+## `/api/pleroma/app_metrics`
+### Exports Prometheus application metrics
+* Method: `GET`
+* Authentication: not required
+* Params: none
+* Response: JSON
+
+## Grafana
+### Config example
+The following is a config example to use with [Grafana](https://grafana.com)
+
+```
+  - job_name: 'beam'
+    metrics_path: /api/pleroma/app_metrics
+    scheme: https
+    static_configs:
+    - targets: ['pleroma.soykaf.com']
+```
diff --git a/docs/config.md b/docs/config.md
index dd3cc3727..22d2a3e7c 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -105,7 +105,7 @@ config :pleroma, Pleroma.Mailer,
 * `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). (Default: `false`)
 
 ## :logger
-* `backends`: `:console` is used to send logs to stdout, `{ExSyslogger, :ex_syslogger}` to log to syslog
+* `backends`: `:console` is used to send logs to stdout, `{ExSyslogger, :ex_syslogger}` to log to syslog, and `Quack.Logger` to log to Slack
 
 An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed:
 ```
@@ -128,6 +128,24 @@ config :logger, :ex_syslogger,
 
 See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_syslogger’s documentation](https://hexdocs.pm/ex_syslogger/)
 
+An example of logging info to local syslog, but warn to a Slack channel:
+```
+config :logger,
+  backends: [ {ExSyslogger, :ex_syslogger}, Quack.Logger ],
+  level: :info
+
+config :logger, :ex_syslogger,
+  level: :info,
+  ident: "pleroma",
+  format: "$metadata[$level] $message"
+
+config :quack,
+  level: :warn,
+  meta: [:all],
+  webhook_url: "https://hooks.slack.com/services/YOUR-API-KEY-HERE"
+```
+
+See the [Quack Github](https://github.com/azohra/quack) for more details
 
 ## :frontend_configurations
 
@@ -301,6 +319,7 @@ Pleroma has the following queues:
 * `federator_incoming` - Incoming federation
 * `mailer` - Email sender, see [`Pleroma.Mailer`](#pleroma-mailer)
 * `transmogrifier` - Transmogrifier
+* `scheduled_activities` - Scheduled activities, see [`Pleroma.ScheduledActivities`](#pleromascheduledactivity)
 
 Example:
 
@@ -396,3 +415,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
diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex
index bc3f8caba..ab8861b27 100644
--- a/lib/pleroma/activity.ex
+++ b/lib/pleroma/activity.ex
@@ -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!
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 782d1d589..f0cb7d9a8 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -25,6 +25,7 @@ def start(_type, _args) do
     import Cachex.Spec
 
     Pleroma.Config.DeprecationWarnings.warn()
+    setup_instrumenters()
 
     # Define workers and child supervisors to be supervised
     children =
@@ -103,7 +104,8 @@ def start(_type, _args) do
           ],
           id: :cachex_idem
         ),
-        worker(Pleroma.FlakeId, [])
+        worker(Pleroma.FlakeId, []),
+        worker(Pleroma.ScheduledActivityWorker, [])
       ] ++
         hackney_pool_children() ++
         [
@@ -126,6 +128,24 @@ def start(_type, _args) do
     Supervisor.start_link(children, opts)
   end
 
+  defp setup_instrumenters do
+    require Prometheus.Registry
+
+    :ok =
+      :telemetry.attach(
+        "prometheus-ecto",
+        [:pleroma, :repo, :query],
+        &Pleroma.Repo.Instrumenter.handle_event/4,
+        %{}
+      )
+
+    Prometheus.Registry.register_collector(:prometheus_process_collector)
+    Pleroma.Web.Endpoint.MetricsExporter.setup()
+    Pleroma.Web.Endpoint.PipelineInstrumenter.setup()
+    Pleroma.Web.Endpoint.Instrumenter.setup()
+    Pleroma.Repo.Instrumenter.setup()
+  end
+
   def enabled_hackney_pools do
     [:media] ++
       if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do
diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex
index 1e48749a8..7f1dbe28c 100644
--- a/lib/pleroma/html.ex
+++ b/lib/pleroma/html.ex
@@ -28,21 +28,20 @@ def filter_tags(html, scrubber), do: Scrubber.scrub(html, scrubber)
   def filter_tags(html), do: filter_tags(html, nil)
   def strip_tags(html), do: Scrubber.scrub(html, Scrubber.StripTags)
 
-  # TODO: rename object to activity because that's what it is really working with
-  def get_cached_scrubbed_html_for_object(content, scrubbers, object, module) do
-    key = "#{module}#{generate_scrubber_signature(scrubbers)}|#{object.id}"
+  def get_cached_scrubbed_html_for_activity(content, scrubbers, activity, key \\ "") do
+    key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}"
 
     Cachex.fetch!(:scrubber_cache, key, fn _key ->
-      ensure_scrubbed_html(content, scrubbers, object.data["object"]["fake"] || false)
+      ensure_scrubbed_html(content, scrubbers, activity.data["object"]["fake"] || false)
     end)
   end
 
-  def get_cached_stripped_html_for_object(content, object, module) do
-    get_cached_scrubbed_html_for_object(
+  def get_cached_stripped_html_for_activity(content, activity, key) do
+    get_cached_scrubbed_html_for_activity(
       content,
       HtmlSanitizeEx.Scrubber.StripTags,
-      object,
-      module
+      activity,
+      key
     )
   end
 
diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex
index 4af1bde56..aa5d427ae 100644
--- a/lib/pleroma/repo.ex
+++ b/lib/pleroma/repo.ex
@@ -8,6 +8,10 @@ defmodule Pleroma.Repo do
     adapter: Ecto.Adapters.Postgres,
     migration_timestamps: [type: :naive_datetime_usec]
 
+  defmodule Instrumenter do
+    use Prometheus.EctoInstrumenter
+  end
+
   @doc """
   Dynamically loads the repository url from the
   DATABASE_URL environment variable.
diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex
new file mode 100644
index 000000000..de0e54699
--- /dev/null
+++ b/lib/pleroma/scheduled_activity.ex
@@ -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
index 000000000..65b38622f
--- /dev/null
+++ b/lib/pleroma/scheduled_activity_worker.ex
@@ -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
diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex
index b3a09e49e..78bf31893 100644
--- a/lib/pleroma/web/admin_api/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/admin_api_controller.ex
@@ -25,6 +25,26 @@ def user_delete(conn, %{"nickname" => nickname}) do
     |> json(nickname)
   end
 
+  def user_follow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do
+    with %User{} = follower <- User.get_by_nickname(follower_nick),
+         %User{} = followed <- User.get_by_nickname(followed_nick) do
+      User.follow(follower, followed)
+    end
+
+    conn
+    |> json("ok")
+  end
+
+  def user_unfollow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do
+    with %User{} = follower <- User.get_by_nickname(follower_nick),
+         %User{} = followed <- User.get_by_nickname(followed_nick) do
+      User.unfollow(follower, followed)
+    end
+
+    conn
+    |> json("ok")
+  end
+
   def user_create(
         conn,
         %{"nickname" => nickname, "email" => email, "password" => password}
diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex
index fa2d1cbe7..6d9528c86 100644
--- a/lib/pleroma/web/endpoint.ex
+++ b/lib/pleroma/web/endpoint.ex
@@ -70,6 +70,26 @@ defmodule Pleroma.Web.Endpoint do
     extra: "SameSite=Strict"
   )
 
+  # Note: the plug and its configuration is compile-time this can't be upstreamed yet
+  if proxies = Pleroma.Config.get([__MODULE__, :reverse_proxies]) do
+    plug(RemoteIp, proxies: proxies)
+  end
+
+  defmodule Instrumenter do
+    use Prometheus.PhoenixInstrumenter
+  end
+
+  defmodule PipelineInstrumenter do
+    use Prometheus.PlugPipelineInstrumenter
+  end
+
+  defmodule MetricsExporter do
+    use Prometheus.PlugExporter
+  end
+
+  plug(PipelineInstrumenter)
+  plug(MetricsExporter)
+
   plug(Pleroma.Web.Router)
 
   @doc """
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex
index 08ea5f967..382f07e6b 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex
@@ -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 @@ def get_notifications(user, params \\ %{}) 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}
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
index 0de2cca4e..fc8a2458c 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
@@ -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 @@ def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) 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 @@ def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) 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
@@ -1091,9 +1158,7 @@ def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params)
   end
 
   def index(%{assigns: %{user: user}} = conn, _params) do
-    token =
-      conn
-      |> get_session(:oauth_token)
+    token = get_session(conn, :oauth_token)
 
     if user && token do
       mastodon_emoji = mastodonized_emoji()
@@ -1121,7 +1186,8 @@ def index(%{assigns: %{user: user}} = conn, _params) do
             auto_play_gif: false,
             display_sensitive_media: false,
             reduce_motion: false,
-            max_toot_chars: limit
+            max_toot_chars: limit,
+            mascot: "/images/pleroma-fox-tan-smol.png"
           },
           rights: %{
             delete_others_notice: present?(user.info.is_moderator),
@@ -1193,6 +1259,7 @@ def index(%{assigns: %{user: user}} = conn, _params) do
       |> render("index.html", %{initial_state: initial_state, flavour: flavour})
     else
       conn
+      |> put_session(:return_to, conn.request_path)
       |> redirect(to: "/web/login")
     end
   end
@@ -1277,12 +1344,20 @@ def login(conn, _) do
           scope: Enum.join(app.scopes, " ")
         )
 
-      conn
-      |> redirect(to: path)
+      redirect(conn, to: path)
     end
   end
 
-  defp local_mastodon_root_path(conn), do: mastodon_api_path(conn, :index, ["getting-started"])
+  defp local_mastodon_root_path(conn) do
+    case get_session(conn, :return_to) do
+      nil ->
+        mastodon_api_path(conn, :index, ["getting-started"])
+
+      return_to ->
+        delete_session(conn, :return_to)
+        return_to
+    end
+  end
 
   defp get_or_make_app do
     find_attrs = %{client_name: @local_mastodon_name, redirect_uris: "."}
@@ -1398,6 +1473,23 @@ def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) 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
index 000000000..0aae15ab9
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex
@@ -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
diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex
index 200bb453d..4c0b53bdd 100644
--- a/lib/pleroma/web/mastodon_api/views/status_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -147,10 +147,18 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity}
     content =
       object
       |> render_content()
-      |> HTML.get_cached_scrubbed_html_for_object(
+      |> HTML.get_cached_scrubbed_html_for_activity(
         User.html_filter_policy(opts[:for]),
         activity,
-        __MODULE__
+        "mastoapi:content"
+      )
+
+    summary =
+      (object["summary"] || "")
+      |> HTML.get_cached_scrubbed_html_for_activity(
+        User.html_filter_policy(opts[:for]),
+        activity,
+        "mastoapi:summary"
       )
 
     card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
@@ -182,7 +190,7 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity}
       muted: CommonAPI.thread_muted?(user, activity) || User.mutes?(opts[:for], user),
       pinned: pinned?(activity, user),
       sensitive: sensitive,
-      spoiler_text: object["summary"] || "",
+      spoiler_text: summary,
       visibility: get_visibility(object),
       media_attachments: attachments,
       mentions: mentions,
diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex
index 23bbde1a6..58385a3d1 100644
--- a/lib/pleroma/web/metadata/utils.ex
+++ b/lib/pleroma/web/metadata/utils.ex
@@ -12,7 +12,7 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do
     # html content comes from DB already encoded, decode first and scrub after
     |> HtmlEntities.decode()
     |> String.replace(~r/<br\s?\/?>/, " ")
-    |> HTML.get_cached_stripped_html_for_object(object, __MODULE__)
+    |> HTML.get_cached_stripped_html_for_activity(object, "metadata")
     |> Formatter.demojify()
     |> Formatter.truncate()
   end
diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex
index 26d53df1a..aac8f97fc 100644
--- a/lib/pleroma/web/oauth/oauth_controller.ex
+++ b/lib/pleroma/web/oauth/oauth_controller.ex
@@ -152,6 +152,7 @@ def token_exchange(
     with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)},
          %App{} = app <- get_app_from_request(conn, params),
          {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
+         {:user_active, true} <- {:user_active, !user.info.deactivated},
          scopes <- oauth_scopes(params, app.scopes),
          [] <- scopes -- app.scopes,
          true <- Enum.any?(scopes),
@@ -175,6 +176,11 @@ def token_exchange(
         |> put_status(:forbidden)
         |> json(%{error: "Your login is missing a confirmed e-mail address"})
 
+      {:user_active, false} ->
+        conn
+        |> put_status(:forbidden)
+        |> json(%{error: "Your account is currently disabled"})
+
       _error ->
         put_status(conn, 400)
         |> json(%{error: "Invalid credentials"})
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 605a327fc..3b5ac6fdd 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -140,8 +140,12 @@ defmodule Pleroma.Web.Router do
   scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do
     pipe_through([:admin_api, :oauth_write])
 
+    post("/user/follow", AdminAPIController, :user_follow)
+    post("/user/unfollow", AdminAPIController, :user_unfollow)
+
     get("/users", AdminAPIController, :list_users)
     get("/users/:nickname", AdminAPIController, :user_show)
+
     delete("/user", AdminAPIController, :user_delete)
     patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
     post("/user", AdminAPIController, :user_create)
@@ -240,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)
@@ -274,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/lib/pleroma/web/twitter_api/views/activity_view.ex b/lib/pleroma/web/twitter_api/views/activity_view.ex
index aa1d41fa2..433322eb8 100644
--- a/lib/pleroma/web/twitter_api/views/activity_view.ex
+++ b/lib/pleroma/web/twitter_api/views/activity_view.ex
@@ -254,10 +254,10 @@ def render(
 
     html =
       content
-      |> HTML.get_cached_scrubbed_html_for_object(
+      |> HTML.get_cached_scrubbed_html_for_activity(
         User.html_filter_policy(opts[:for]),
         activity,
-        __MODULE__
+        "twitterapi:content"
       )
       |> Formatter.emojify(object["emoji"])
 
@@ -265,7 +265,7 @@ def render(
       if content do
         content
         |> String.replace(~r/<br\s?\/?>/, "\n")
-        |> HTML.get_cached_stripped_html_for_object(activity, __MODULE__)
+        |> HTML.get_cached_stripped_html_for_activity(activity, "twitterapi:content")
       else
         ""
       end
diff --git a/mix.exs b/mix.exs
index 88da6332a..ec0865c4f 100644
--- a/mix.exs
+++ b/mix.exs
@@ -41,7 +41,7 @@ def project do
   def application do
     [
       mod: {Pleroma.Application, []},
-      extra_applications: [:logger, :runtime_tools, :comeonin],
+      extra_applications: [:logger, :runtime_tools, :comeonin, :quack],
       included_applications: [:ex_syslogger]
     ]
   end
@@ -94,7 +94,15 @@ defp deps do
       {:auto_linker,
        git: "https://git.pleroma.social/pleroma/auto_linker.git",
        ref: "479dd343f4e563ff91215c8275f3b5c67e032850"},
-      {:pleroma_job_queue, "~> 0.2.0"}
+      {:pleroma_job_queue, "~> 0.2.0"},
+      {:telemetry, "~> 0.3"},
+      {:prometheus_ex, "~> 3.0"},
+      {:prometheus_plugs, "~> 1.1"},
+      {:prometheus_phoenix, "~> 1.2"},
+      {:prometheus_ecto, "~> 1.4"},
+      {:prometheus_process_collector, "~> 1.4"},
+      {:recon, github: "ferd/recon", tag: "2.4.0"},
+      {:quack, "~> 0.1.1"}
     ]
   end
 
diff --git a/mix.lock b/mix.lock
index 9c454446a..7c7e322de 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,4 +1,5 @@
 %{
+  "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"},
   "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "479dd343f4e563ff91215c8275f3b5c67e032850", [ref: "479dd343f4e563ff91215c8275f3b5c67e032850"]},
   "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
   "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
@@ -57,7 +58,15 @@
   "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
   "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
   "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
+  "prometheus": {:hex, :prometheus, "4.2.2", "a830e77b79dc6d28183f4db050a7cac926a6c58f1872f9ef94a35cd989aceef8", [:mix, :rebar3], [], "hexpm"},
+  "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.1", "6c768ea9654de871e5b32fab2eac348467b3021604ebebbcbd8bcbe806a65ed5", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"},
+  "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"},
+  "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.2.1", "964a74dfbc055f781d3a75631e06ce3816a2913976d1df7830283aa3118a797a", [:mix], [{:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"},
+  "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"},
+  "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.0", "6dbd39e3165b9ef1c94a7a820e9ffe08479f949dcdd431ed4aaea7b250eebfde", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"},
+  "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"},
   "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
+  "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]},
   "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
   "swoosh": {:hex, :swoosh, "0.20.0", "9a6c13822c9815993c03b6f8fccc370fcffb3c158d9754f67b1fdee6b3a5d928", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.12", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},
   "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]},
diff --git a/priv/repo/migrations/20190328053912_create_scheduled_activities.exs b/priv/repo/migrations/20190328053912_create_scheduled_activities.exs
new file mode 100644
index 000000000..dd737e25a
--- /dev/null
+++ b/priv/repo/migrations/20190328053912_create_scheduled_activities.exs
@@ -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/priv/repo/migrations/20190403131720_add_oauth_token_indexes.exs b/priv/repo/migrations/20190403131720_add_oauth_token_indexes.exs
new file mode 100644
index 000000000..ebcd29389
--- /dev/null
+++ b/priv/repo/migrations/20190403131720_add_oauth_token_indexes.exs
@@ -0,0 +1,9 @@
+defmodule Pleroma.Repo.Migrations.AddOauthTokenIndexes do
+  use Ecto.Migration
+
+  def change do
+    create(unique_index(:oauth_tokens, [:token]))
+    create(index(:oauth_tokens, [:app_id]))
+    create(index(:oauth_tokens, [:user_id]))
+  end
+end
diff --git a/test/plugs/legacy_authentication_plug_test.exs b/test/plugs/legacy_authentication_plug_test.exs
index 302662797..8b0b06772 100644
--- a/test/plugs/legacy_authentication_plug_test.exs
+++ b/test/plugs/legacy_authentication_plug_test.exs
@@ -47,16 +47,18 @@ test "it authenticates the auth_user if present and password is correct and rese
       |> assign(:auth_user, user)
 
     conn =
-      with_mock User,
-        reset_password: fn user, %{password: password, password_confirmation: password} ->
-          send(self(), :reset_password)
-          {:ok, user}
-        end do
-        conn
-        |> LegacyAuthenticationPlug.call(%{})
+      with_mocks([
+        {:crypt, [], [crypt: fn _password, password_hash -> password_hash end]},
+        {User, [],
+         [
+           reset_password: fn user, %{password: password, password_confirmation: password} ->
+             {:ok, user}
+           end
+         ]}
+      ]) do
+        LegacyAuthenticationPlug.call(conn, %{})
       end
 
-    assert_received :reset_password
     assert conn.assigns.user == user
   end
 
diff --git a/test/scheduled_activity_test.exs b/test/scheduled_activity_test.exs
new file mode 100644
index 000000000..edc7cc3f9
--- /dev/null
+++ b/test/scheduled_activity_test.exs
@@ -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
index 000000000..b9c91dda6
--- /dev/null
+++ b/test/scheduled_activity_worker_test.exs
@@ -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
diff --git a/test/support/factory.ex b/test/support/factory.ex
index e1a08315a..608f8d46b 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -240,6 +240,16 @@ def oauth_token_factory do
     }
   end
 
+  def oauth_authorization_factory do
+    %Pleroma.Web.OAuth.Authorization{
+      token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
+      scopes: ["read", "write", "follow", "push"],
+      valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
+      user: build(:user),
+      app: build(:oauth_app)
+    }
+  end
+
   def push_subscription_factory do
     %Pleroma.Web.Push.Subscription{
       user: build(:user),
@@ -257,4 +267,12 @@ def notification_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
diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs
index acae64361..dd2fbfb15 100644
--- a/test/web/admin_api/admin_api_controller_test.exs
+++ b/test/web/admin_api/admin_api_controller_test.exs
@@ -74,6 +74,52 @@ test "when the user doesn't exist", %{conn: conn} do
     end
   end
 
+  describe "/api/pleroma/admin/user/follow" do
+    test "allows to force-follow another user" do
+      admin = insert(:user, info: %{is_admin: true})
+      user = insert(:user)
+      follower = insert(:user)
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> put_req_header("accept", "application/json")
+        |> post("/api/pleroma/admin/user/follow", %{
+          "follower" => follower.nickname,
+          "followed" => user.nickname
+        })
+
+      user = User.get_by_id(user.id)
+      follower = User.get_by_id(follower.id)
+
+      assert User.following?(follower, user)
+    end
+  end
+
+  describe "/api/pleroma/admin/user/unfollow" do
+    test "allows to force-unfollow another user" do
+      admin = insert(:user, info: %{is_admin: true})
+      user = insert(:user)
+      follower = insert(:user)
+
+      User.follow(follower, user)
+
+      conn =
+        build_conn()
+        |> assign(:user, admin)
+        |> put_req_header("accept", "application/json")
+        |> post("/api/pleroma/admin/user/unfollow", %{
+          "follower" => follower.nickname,
+          "followed" => user.nickname
+        })
+
+      user = User.get_by_id(user.id)
+      follower = User.get_by_id(follower.id)
+
+      refute User.following?(follower, user)
+    end
+  end
+
   describe "PUT /api/pleroma/admin/users/tag" do
     setup do
       admin = insert(:user, info: %{is_admin: true})
diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs
index 6060cc97f..cd01116e2 100644
--- a/test/web/mastodon_api/mastodon_api_controller_test.exs
+++ b/test/web/mastodon_api/mastodon_api_controller_test.exs
@@ -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
@@ -2340,4 +2341,281 @@ test "accounts fetches correct account for nicknames beginning with numbers", %{
     refute acc_one == acc_two
     assert acc_two == acc_three
   end
+
+  describe "index/2 redirections" do
+    setup %{conn: conn} do
+      session_opts = [
+        store: :cookie,
+        key: "_test",
+        signing_salt: "cooldude"
+      ]
+
+      conn =
+        conn
+        |> Plug.Session.call(Plug.Session.init(session_opts))
+        |> fetch_session()
+
+      test_path = "/web/statuses/test"
+      %{conn: conn, path: test_path}
+    end
+
+    test "redirects not logged-in users to the login page", %{conn: conn, path: path} do
+      conn = get(conn, path)
+
+      assert conn.status == 302
+      assert redirected_to(conn) == "/web/login"
+    end
+
+    test "does not redirect logged in users to the login page", %{conn: conn, path: path} do
+      token = insert(:oauth_token)
+
+      conn =
+        conn
+        |> assign(:user, token.user)
+        |> put_session(:oauth_token, token.token)
+        |> get(path)
+
+      assert conn.status == 200
+    end
+
+    test "saves referer path to session", %{conn: conn, path: path} do
+      conn = get(conn, path)
+      return_to = Plug.Conn.get_session(conn, :return_to)
+
+      assert return_to == path
+    end
+
+    test "redirects to the saved path after log in", %{conn: conn, path: path} do
+      app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".")
+      auth = insert(:oauth_authorization, app: app)
+
+      conn =
+        conn
+        |> put_session(:return_to, path)
+        |> get("/web/login", %{code: auth.token})
+
+      assert conn.status == 302
+      assert redirected_to(conn) == path
+    end
+
+    test "redirects to the getting-started page when referer is not present", %{conn: conn} do
+      app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".")
+      auth = insert(:oauth_authorization, app: app)
+
+      conn = get(conn, "/web/login", %{code: auth.token})
+
+      assert conn.status == 302
+      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
index 000000000..ecbb855d4
--- /dev/null
+++ b/test/web/mastodon_api/scheduled_activity_view_test.exs
@@ -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
diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs
index a9a0b9ed4..a68528420 100644
--- a/test/web/oauth/oauth_controller_test.exs
+++ b/test/web/oauth/oauth_controller_test.exs
@@ -327,6 +327,32 @@ test "rejects token exchange for valid credentials belonging to unconfirmed user
       refute Map.has_key?(resp, "access_token")
     end
 
+    test "rejects token exchange for valid credentials belonging to deactivated user" do
+      password = "testpassword"
+
+      user =
+        insert(:user,
+          password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
+          info: %{deactivated: true}
+        )
+
+      app = insert(:oauth_app)
+
+      conn =
+        build_conn()
+        |> post("/oauth/token", %{
+          "grant_type" => "password",
+          "username" => user.nickname,
+          "password" => password,
+          "client_id" => app.client_id,
+          "client_secret" => app.client_secret
+        })
+
+      assert resp = json_response(conn, 403)
+      assert %{"error" => _} = resp
+      refute Map.has_key?(resp, "access_token")
+    end
+
     test "rejects an invalid authorization code" do
       app = insert(:oauth_app)