diff options
Diffstat (limited to 'lib/pleroma/web/push')
| -rw-r--r-- | lib/pleroma/web/push/impl.ex | 205 | ||||
| -rw-r--r-- | lib/pleroma/web/push/subscription.ex | 102 |
2 files changed, 307 insertions, 0 deletions
diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex new file mode 100644 index 0000000..3c5f007 --- /dev/null +++ b/lib/pleroma/web/push/impl.ex @@ -0,0 +1,205 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Push.Impl do + @moduledoc "The module represents implementation push web notification" + + alias Pleroma.Activity + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Push.Subscription + + require Logger + import Ecto.Query + + @types ["Create", "Follow", "Announce", "Like", "Move", "EmojiReact", "Update"] + + @doc "Performs sending notifications for user subscriptions" + @spec perform(Notification.t()) :: list(any) | :error | {:error, :unknown_type} + def perform( + %{ + activity: %{data: %{"type" => activity_type}} = activity, + user: %User{id: user_id} + } = notification + ) + when activity_type in @types do + actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) + + mastodon_type = notification.type + gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) + avatar_url = User.avatar_url(actor) + object = Object.normalize(activity, fetch: false) + user = User.get_cached_by_id(user_id) + direct_conversation_id = Activity.direct_conversation_id(activity, user) + + for subscription <- fetch_subscriptions(user_id), + Subscription.enabled?(subscription, mastodon_type) do + %{ + access_token: subscription.token.token, + notification_id: notification.id, + notification_type: mastodon_type, + icon: avatar_url, + preferred_locale: "en", + pleroma: %{ + activity_id: notification.activity.id, + direct_conversation_id: direct_conversation_id + } + } + |> Map.merge(build_content(notification, actor, object, mastodon_type)) + |> Jason.encode!() + |> push_message(build_sub(subscription), gcm_api_key, subscription) + end + |> (&{:ok, &1}).() + end + + def perform(_) do + Logger.warn("Unknown notification type") + {:error, :unknown_type} + end + + @doc "Push message to web" + def push_message(body, sub, api_key, subscription) do + case WebPushEncryption.send_web_push(body, sub, api_key) do + {:ok, %{status: code}} when code in 400..499 -> + Logger.debug("Removing subscription record") + Repo.delete!(subscription) + :ok + + {:ok, %{status: code}} when code in 200..299 -> + :ok + + {:ok, %{status: code}} -> + Logger.error("Web Push Notification failed with code: #{code}") + :error + + error -> + Logger.error("Web Push Notification failed with #{inspect(error)}") + :error + end + end + + @doc "Gets user subscriptions" + def fetch_subscriptions(user_id) do + Subscription + |> where(user_id: ^user_id) + |> preload(:token) + |> Repo.all() + end + + def build_sub(subscription) do + %{ + keys: %{ + p256dh: subscription.key_p256dh, + auth: subscription.key_auth + }, + endpoint: subscription.endpoint + } + end + + def build_content(notification, actor, object, mastodon_type \\ nil) + + def build_content( + %{ + user: %{notification_settings: %{hide_notification_contents: true}} + } = notification, + _actor, + _object, + mastodon_type + ) do + %{body: format_title(notification, mastodon_type)} + end + + def build_content(notification, actor, object, mastodon_type) do + mastodon_type = mastodon_type || notification.type + + %{ + title: format_title(notification, mastodon_type), + body: format_body(notification, actor, object, mastodon_type) + } + end + + def format_body(activity, actor, object, mastodon_type \\ nil) + + def format_body(_activity, actor, %{data: %{"type" => "ChatMessage"} = data}, _) do + case data["content"] do + nil -> "@#{actor.nickname}: (Attachment)" + content -> "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" + end + end + + def format_body( + %{activity: %{data: %{"type" => "Create"}}}, + actor, + %{data: %{"content" => content}}, + _mastodon_type + ) do + "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" + end + + def format_body( + %{activity: %{data: %{"type" => "Announce"}}}, + actor, + %{data: %{"content" => content}}, + _mastodon_type + ) do + "@#{actor.nickname} repeated: #{Utils.scrub_html_and_truncate(content, 80)}" + end + + def format_body( + %{activity: %{data: %{"type" => "EmojiReact", "content" => content}}}, + actor, + _object, + _mastodon_type + ) do + "@#{actor.nickname} reacted with #{content}" + end + + def format_body( + %{activity: %{data: %{"type" => type}}} = notification, + actor, + _object, + mastodon_type + ) + when type in ["Follow", "Like"] do + mastodon_type = mastodon_type || notification.type + + case mastodon_type do + "follow" -> "@#{actor.nickname} has followed you" + "follow_request" -> "@#{actor.nickname} has requested to follow you" + "favourite" -> "@#{actor.nickname} has favorited your post" + end + end + + def format_body( + %{activity: %{data: %{"type" => "Update"}}}, + actor, + _object, + _mastodon_type + ) do + "@#{actor.nickname} edited a status" + end + + def format_title(activity, mastodon_type \\ nil) + + def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_type) do + "New Direct Message" + end + + def format_title(%{type: type}, mastodon_type) do + case mastodon_type || type do + "mention" -> "New Mention" + "follow" -> "New Follower" + "follow_request" -> "New Follow Request" + "reblog" -> "New Repeat" + "favourite" -> "New Favorite" + "update" -> "New Update" + "pleroma:chat_mention" -> "New Chat Message" + "pleroma:emoji_reaction" -> "New Reaction" + type -> "New #{String.capitalize(type || "event")}" + end + end +end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex new file mode 100644 index 0000000..6fc45bd --- /dev/null +++ b/lib/pleroma/web/push/subscription.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Push.Subscription do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.Push.Subscription + + @type t :: %__MODULE__{} + + schema "push_subscriptions" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:token, Token) + field(:endpoint, :string) + field(:key_p256dh, :string) + field(:key_auth, :string) + field(:data, :map, default: %{}) + + timestamps() + end + + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a + + defp alerts(%{data: %{alerts: alerts}}) do + alerts = Map.take(alerts, @supported_alert_types) + %{"alerts" => alerts} + end + + def enabled?(subscription, "follow_request") do + enabled?(subscription, "follow") + end + + def enabled?(subscription, alert_type) do + get_in(subscription.data, ["alerts", alert_type]) + end + + def create( + %User{} = user, + %Token{} = token, + %{ + subscription: %{ + endpoint: endpoint, + keys: %{auth: key_auth, p256dh: key_p256dh} + } + } = params + ) do + Repo.insert(%Subscription{ + user_id: user.id, + token_id: token.id, + endpoint: endpoint, + key_auth: ensure_base64_urlsafe(key_auth), + key_p256dh: ensure_base64_urlsafe(key_p256dh), + data: alerts(params) + }) + end + + @doc "Gets subsciption by user & token" + @spec get(User.t(), Token.t()) :: {:ok, t()} | {:error, :not_found} + def get(%User{id: user_id}, %Token{id: token_id}) do + case Repo.get_by(Subscription, user_id: user_id, token_id: token_id) do + nil -> {:error, :not_found} + subscription -> {:ok, subscription} + end + end + + def update(user, token, params) do + with {:ok, subscription} <- get(user, token) do + subscription + |> change(data: alerts(params)) + |> Repo.update() + end + end + + def delete(user, token) do + with {:ok, subscription} <- get(user, token), + do: Repo.delete(subscription) + end + + def delete_if_exists(user, token) do + case get(user, token) do + {:error, _} -> {:ok, nil} + {:ok, 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 |
