Merge branch 'pleroma-feature/compat/push-subscriptions' into 'develop'
authorhref <href+git-pleroma@random.sh>
Fri, 14 Dec 2018 18:50:44 +0000 (18:50 +0000)
committerhref <href+git-pleroma@random.sh>
Fri, 14 Dec 2018 18:50:44 +0000 (18:50 +0000)
Improve web push

Closes #393, #422, and #452

See merge request pleroma/pleroma!524

config/config.md
lib/pleroma/activity.ex
lib/pleroma/plugs/oauth_plug.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/views/push_subscription_view.ex
lib/pleroma/web/push/push.ex
lib/pleroma/web/push/subscription.ex
lib/pleroma/web/twitter_api/controllers/util_controller.ex

index 165f5d9f8c8256d61c6844bff6f85208a9c825e8..d4dad77b1fb90dd838beefc8121d38711a79ff45 100644 (file)
@@ -154,3 +154,11 @@ An example:
 config :pleroma, :mrf_user_allowlist,
   "example.org": ["https://example.org/users/admin"]
 ```
+
+## :web_push_encryption, :vapid_details
+
+Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it.
+
+* ``subject``: a mailto link for the administrative contact. It’s best if this email is not a personal email address, but rather a group email so that if a person leaves an organization, is unavailable for an extended period, or otherwise can’t respond, someone else on the list can.
+* ``public_key``: VAPID public key
+* ``private_key``: VAPID private key
index c065f3b6ce690a688ead5a087135f46e44f13ba8..200addd6ebdba96a600c4a46816dd7b7559bb421 100644 (file)
@@ -3,6 +3,14 @@ defmodule Pleroma.Activity do
   alias Pleroma.{Repo, Activity, Notification}
   import Ecto.Query
 
+  # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19
+  @mastodon_notification_types %{
+    "Create" => "mention",
+    "Follow" => "follow",
+    "Announce" => "reblog",
+    "Like" => "favourite"
+  }
+
   schema "activities" do
     field(:data, :map)
     field(:local, :boolean, default: true)
@@ -88,4 +96,11 @@ defmodule Pleroma.Activity do
   end
 
   def get_in_reply_to_activity(_), do: nil
+
+  for {ap_type, type} <- @mastodon_notification_types do
+    def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}),
+      do: unquote(type)
+  end
+
+  def mastodon_notification_type(%Activity{}), do: nil
 end
index 8b99a74d1f49f2c0d197990c86634adb797d86e0..13c914c1b7f3fae2542ad54be418934efcfec6aa 100644 (file)
@@ -15,10 +15,10 @@ defmodule Pleroma.Plugs.OAuthPlug do
   def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
 
   def call(conn, _) do
-    with {:ok, token} <- fetch_token(conn),
-         {:ok, user} <- fetch_user(token) do
+    with {:ok, token_str} <- fetch_token_str(conn),
+         {:ok, user, token_record} <- fetch_user_and_token(token_str) do
       conn
-      |> assign(:token, token)
+      |> assign(:token, token_record)
       |> assign(:user, user)
     else
       _ -> conn
@@ -27,12 +27,12 @@ defmodule Pleroma.Plugs.OAuthPlug do
 
   # Gets user by token
   #
-  @spec fetch_user(String.t()) :: {:ok, User.t()} | nil
-  defp fetch_user(token) do
+  @spec fetch_user_and_token(String.t()) :: {:ok, User.t(), Token.t()} | nil
+  defp fetch_user_and_token(token) do
     query = from(q in Token, where: q.token == ^token, preload: [:user])
 
-    with %Token{user: %{info: %{deactivated: false} = _} = user} <- Repo.one(query) do
-      {:ok, user}
+    with %Token{user: %{info: %{deactivated: false} = _} = user} = token_record <- Repo.one(query) do
+      {:ok, user, token_record}
     end
   end
 
@@ -48,23 +48,23 @@ defmodule Pleroma.Plugs.OAuthPlug do
 
   # Gets token from headers
   #
-  @spec fetch_token(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
-  defp fetch_token(%Plug.Conn{} = conn) do
+  @spec fetch_token_str(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
+  defp fetch_token_str(%Plug.Conn{} = conn) do
     headers = get_req_header(conn, "authorization")
 
-    with :no_token_found <- fetch_token(headers),
+    with :no_token_found <- fetch_token_str(headers),
          do: fetch_token_from_session(conn)
   end
 
-  @spec fetch_token(Keyword.t()) :: :no_token_found | {:ok, String.t()}
-  defp fetch_token([]), do: :no_token_found
+  @spec fetch_token_str(Keyword.t()) :: :no_token_found | {:ok, String.t()}
+  defp fetch_token_str([]), do: :no_token_found
 
-  defp fetch_token([token | tail]) do
+  defp fetch_token_str([token | tail]) do
     trimmed_token = String.trim(token)
 
     case Regex.run(@realm_reg, trimmed_token) do
       [_, match] -> {:ok, String.trim(match)}
-      _ -> fetch_token(tail)
+      _ -> fetch_token_str(tail)
     end
   end
 end
index 5c8602322f23310592f0aebcb4ac0e6a5ae7ac37..0414d73d86d815af5a4f3b64d1b06f6edc284789 100644 (file)
@@ -1055,52 +1055,37 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
 
   def render_notification(user, %{id: id, activity: activity, inserted_at: created_at} = _params) do
     actor = User.get_cached_by_ap_id(activity.data["actor"])
+    parent_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
+    mastodon_type = Activity.mastodon_notification_type(activity)
 
-    created_at =
-      NaiveDateTime.to_iso8601(created_at)
-      |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
-
-    id = id |> to_string
+    response = %{
+      id: to_string(id),
+      type: mastodon_type,
+      created_at: CommonAPI.Utils.to_masto_date(created_at),
+      account: AccountView.render("account.json", %{user: actor, for: user})
+    }
 
-    case activity.data["type"] do
-      "Create" ->
-        %{
-          id: id,
-          type: "mention",
-          created_at: created_at,
-          account: AccountView.render("account.json", %{user: actor, for: user}),
+    case mastodon_type do
+      "mention" ->
+        response
+        |> Map.merge(%{
           status: StatusView.render("status.json", %{activity: activity, for: user})
-        }
+        })
 
-      "Like" ->
-        liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
+      "favourite" ->
+        response
+        |> Map.merge(%{
+          status: StatusView.render("status.json", %{activity: parent_activity, for: user})
+        })
 
-        %{
-          id: id,
-          type: "favourite",
-          created_at: created_at,
-          account: AccountView.render("account.json", %{user: actor, for: user}),
-          status: StatusView.render("status.json", %{activity: liked_activity, for: user})
-        }
+      "reblog" ->
+        response
+        |> Map.merge(%{
+          status: StatusView.render("status.json", %{activity: parent_activity, for: user})
+        })
 
-      "Announce" ->
-        announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
-
-        %{
-          id: id,
-          type: "reblog",
-          created_at: created_at,
-          account: AccountView.render("account.json", %{user: actor, for: user}),
-          status: StatusView.render("status.json", %{activity: announced_activity, for: user})
-        }
-
-      "Follow" ->
-        %{
-          id: id,
-          type: "follow",
-          created_at: created_at,
-          account: AccountView.render("account.json", %{user: actor, for: user})
-        }
+      "follow" ->
+        response
 
       _ ->
         nil
@@ -1167,6 +1152,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
   end
 
   def create_push_subscription(%{assigns: %{user: user, token: token}} = conn, params) do
+    true = Pleroma.Web.Push.enabled()
     Pleroma.Web.Push.Subscription.delete_if_exists(user, token)
     {:ok, subscription} = Pleroma.Web.Push.Subscription.create(user, token, params)
     view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
@@ -1174,6 +1160,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
   end
 
   def get_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do
+    true = Pleroma.Web.Push.enabled()
     subscription = Pleroma.Web.Push.Subscription.get(user, token)
     view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
     json(conn, view)
@@ -1183,12 +1170,14 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
         %{assigns: %{user: user, token: token}} = conn,
         params
       ) do
+    true = Pleroma.Web.Push.enabled()
     {:ok, subscription} = Pleroma.Web.Push.Subscription.update(user, token, params)
     view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
     json(conn, view)
   end
 
   def delete_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do
+    true = Pleroma.Web.Push.enabled()
     {:ok, _response} = Pleroma.Web.Push.Subscription.delete(user, token)
     json(conn, %{})
   end
index c8b95d14cb420c82eecfc107f57c4a857ddf8017..67e86294e6c8739962da35ce8e45388d4ddedbf2 100644 (file)
@@ -5,7 +5,12 @@ defmodule Pleroma.Web.MastodonAPI.PushSubscriptionView do
     %{
       id: to_string(subscription.id),
       endpoint: subscription.endpoint,
-      alerts: Map.get(subscription.data, "alerts")
+      alerts: Map.get(subscription.data, "alerts"),
+      server_key: server_key()
     }
   end
+
+  defp server_key do
+    Keyword.get(Application.get_env(:web_push_encryption, :vapid_details), :public_key)
+  end
 end
index 5a873ec19cf3231c4079ef731e71c7525ce983b9..4779434504fc917a68b3e0b34e0ec835f90c9d37 100644 (file)
@@ -9,67 +9,99 @@ defmodule Pleroma.Web.Push do
 
   @types ["Create", "Follow", "Announce", "Like"]
 
-  @gcm_api_key nil
-
   def start_link() do
     GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
   end
 
-  def init(:ok) do
-    case Application.get_env(:web_push_encryption, :vapid_details) do
-      nil ->
-        Logger.warn(
-          "VAPID key pair is not found. Please, add VAPID configuration to config. Run `mix web_push.gen.keypair` mix task to create a key pair"
-        )
-
-        :ignore
+  def vapid_config() do
+    Application.get_env(:web_push_encryption, :vapid_details, [])
+  end
 
-      _ ->
-        {:ok, %{}}
+  def enabled() do
+    case vapid_config() do
+      [] -> false
+      list when is_list(list) -> true
+      _ -> false
     end
   end
 
   def send(notification) do
-    if Application.get_env(:web_push_encryption, :vapid_details) do
+    if enabled() do
       GenServer.cast(Pleroma.Web.Push, {:send, notification})
     end
   end
 
+  def init(:ok) do
+    if !enabled() do
+      Logger.warn("""
+      VAPID key pair is not found. If you wish to enabled web push, please run
+
+          mix web_push.gen.keypair
+
+      and add the resulting output to your configuration file.
+      """)
+
+      :ignore
+    else
+      {:ok, nil}
+    end
+  end
+
   def handle_cast(
         {:send, %{activity: %{data: %{"type" => type}}, user_id: user_id} = notification},
         state
       )
       when type in @types do
     actor = User.get_cached_by_ap_id(notification.activity.data["actor"])
-    body = notification |> format(actor) |> Jason.encode!()
+
+    type = Pleroma.Activity.mastodon_notification_type(notification.activity)
 
     Subscription
     |> where(user_id: ^user_id)
+    |> preload(:token)
     |> Repo.all()
-    |> Enum.each(fn record ->
-      subscription = %{
+    |> Enum.filter(fn subscription ->
+      get_in(subscription.data, ["alerts", type]) || false
+    end)
+    |> Enum.each(fn subscription ->
+      sub = %{
         keys: %{
-          p256dh: record.key_p256dh,
-          auth: record.key_auth
+          p256dh: subscription.key_p256dh,
+          auth: subscription.key_auth
         },
-        endpoint: record.endpoint
+        endpoint: subscription.endpoint
       }
 
-      case WebPushEncryption.send_web_push(body, subscription, @gcm_api_key) do
+      body =
+        Jason.encode!(%{
+          title: format_title(notification),
+          access_token: subscription.token.token,
+          body: format_body(notification, actor),
+          notification_id: notification.id,
+          notification_type: type,
+          icon: User.avatar_url(actor),
+          preferred_locale: "en"
+        })
+
+      case WebPushEncryption.send_web_push(
+             body,
+             sub,
+             Application.get_env(:web_push_encryption, :gcm_api_key)
+           ) do
         {:ok, %{status_code: code}} when 400 <= code and code < 500 ->
           Logger.debug("Removing subscription record")
-          Repo.delete!(record)
+          Repo.delete!(subscription)
           :ok
 
         {:ok, %{status_code: code}} when 200 <= code and code < 300 ->
           :ok
 
         {:ok, %{status_code: code}} ->
-          Logger.error("Web Push Nonification failed with code: #{code}")
+          Logger.error("Web Push Notification failed with code: #{code}")
           :error
 
         _ ->
-          Logger.error("Web Push Nonification failed with unknown error")
+          Logger.error("Web Push Notification failed with unknown error")
           :error
       end
     end)
@@ -82,35 +114,21 @@ defmodule Pleroma.Web.Push do
     {:noreply, state}
   end
 
-  def format(%{activity: %{data: %{"type" => "Create"}}}, actor) do
-    %{
-      title: "New Mention",
-      body: "@#{actor.nickname} has mentiond you",
-      icon: User.avatar_url(actor)
-    }
-  end
-
-  def format(%{activity: %{data: %{"type" => "Follow"}}}, actor) do
-    %{
-      title: "New Follower",
-      body: "@#{actor.nickname} has followed you",
-      icon: User.avatar_url(actor)
-    }
-  end
-
-  def format(%{activity: %{data: %{"type" => "Announce"}}}, actor) do
-    %{
-      title: "New Announce",
-      body: "@#{actor.nickname} has announced your post",
-      icon: User.avatar_url(actor)
-    }
+  defp format_title(%{activity: %{data: %{"type" => type}}}) do
+    case type do
+      "Create" -> "New Mention"
+      "Follow" -> "New Follower"
+      "Announce" -> "New Repeat"
+      "Like" -> "New Favorite"
+    end
   end
 
-  def format(%{activity: %{data: %{"type" => "Like"}}}, actor) do
-    %{
-      title: "New Like",
-      body: "@#{actor.nickname} has liked your post",
-      icon: User.avatar_url(actor)
-    }
+  defp format_body(%{activity: %{data: %{"type" => type}}}, actor) do
+    case type do
+      "Create" -> "@#{actor.nickname} has mentioned you"
+      "Follow" -> "@#{actor.nickname} has followed you"
+      "Announce" -> "@#{actor.nickname} has repeated your post"
+      "Like" -> "@#{actor.nickname} has favorited your post"
+    end
   end
 end
index cfab7a98e0a172867ced5205487bffff89a77823..1ad405daf8138904282089a98a081700fc9f2b22 100644 (file)
@@ -37,8 +37,8 @@ defmodule Pleroma.Web.Push.Subscription do
       user_id: user.id,
       token_id: token.id,
       endpoint: endpoint,
-      key_auth: key_auth,
-      key_p256dh: key_p256dh,
+      key_auth: ensure_base64_urlsafe(key_auth),
+      key_p256dh: ensure_base64_urlsafe(key_p256dh),
       data: alerts(params)
     })
   end
@@ -63,4 +63,14 @@ defmodule Pleroma.Web.Push.Subscription do
       sub -> Repo.delete(sub)
     end
   end
+
+  # Some webpush clients (e.g. iOS Toot!) use an non urlsafe base64 as an encoding for the key.
+  # However, the web push rfs specify to use base64 urlsafe, and the `web_push_encryption` library we use
+  # requires the key to be properly encoded. So we just convert base64 to urlsafe base64.
+  defp ensure_base64_urlsafe(string) do
+    string
+    |> String.replace("+", "-")
+    |> String.replace("/", "_")
+    |> String.replace("=", "")
+  end
 end
index 387f5bd716335e76a0097074cc219ad8044817ae..a8e3467c46a0398bd6827b351d867a652f7ade46 100644 (file)
@@ -156,8 +156,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
         |> send_resp(200, response)
 
       _ ->
-        vapid_public_key =
-          Keyword.get(Application.get_env(:web_push_encryption, :vapid_details), :public_key)
+        vapid_public_key = Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
 
         uploadlimit = %{
           uploadlimit: to_string(Keyword.get(instance, :upload_limit)),