Merge branch 'pleroma-feature/compat/push-subscriptions' into 'develop'

Improve web push

Closes #393, #422, and #452

See merge request pleroma/pleroma!524
This commit is contained in:
href 2018-12-14 18:50:44 +00:00
commit 980131b4db
8 changed files with 154 additions and 110 deletions

View file

@ -154,3 +154,11 @@ An example:
config :pleroma, :mrf_user_allowlist, config :pleroma, :mrf_user_allowlist,
"example.org": ["https://example.org/users/admin"] "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. Its 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 cant respond, someone else on the list can.
* ``public_key``: VAPID public key
* ``private_key``: VAPID private key

View file

@ -3,6 +3,14 @@ defmodule Pleroma.Activity do
alias Pleroma.{Repo, Activity, Notification} alias Pleroma.{Repo, Activity, Notification}
import Ecto.Query 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 schema "activities" do
field(:data, :map) field(:data, :map)
field(:local, :boolean, default: true) field(:local, :boolean, default: true)
@ -88,4 +96,11 @@ def get_in_reply_to_activity(%Activity{data: %{"object" => %{"inReplyTo" => ap_i
end end
def get_in_reply_to_activity(_), do: nil 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 end

View file

@ -15,10 +15,10 @@ def init(options), do: options
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(conn, _) do def call(conn, _) do
with {:ok, token} <- fetch_token(conn), with {:ok, token_str} <- fetch_token_str(conn),
{:ok, user} <- fetch_user(token) do {:ok, user, token_record} <- fetch_user_and_token(token_str) do
conn conn
|> assign(:token, token) |> assign(:token, token_record)
|> assign(:user, user) |> assign(:user, user)
else else
_ -> conn _ -> conn
@ -27,12 +27,12 @@ def call(conn, _) do
# Gets user by token # Gets user by token
# #
@spec fetch_user(String.t()) :: {:ok, User.t()} | nil @spec fetch_user_and_token(String.t()) :: {:ok, User.t(), Token.t()} | nil
defp fetch_user(token) do defp fetch_user_and_token(token) do
query = from(q in Token, where: q.token == ^token, preload: [:user]) query = from(q in Token, where: q.token == ^token, preload: [:user])
with %Token{user: %{info: %{deactivated: false} = _} = user} <- Repo.one(query) do with %Token{user: %{info: %{deactivated: false} = _} = user} = token_record <- Repo.one(query) do
{:ok, user} {:ok, user, token_record}
end end
end end
@ -48,23 +48,23 @@ defp fetch_token_from_session(conn) do
# Gets token from headers # Gets token from headers
# #
@spec fetch_token(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} @spec fetch_token_str(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token(%Plug.Conn{} = conn) do defp fetch_token_str(%Plug.Conn{} = conn) do
headers = get_req_header(conn, "authorization") 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) do: fetch_token_from_session(conn)
end end
@spec fetch_token(Keyword.t()) :: :no_token_found | {:ok, String.t()} @spec fetch_token_str(Keyword.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token([]), do: :no_token_found 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) trimmed_token = String.trim(token)
case Regex.run(@realm_reg, trimmed_token) do case Regex.run(@realm_reg, trimmed_token) do
[_, match] -> {:ok, String.trim(match)} [_, match] -> {:ok, String.trim(match)}
_ -> fetch_token(tail) _ -> fetch_token_str(tail)
end end
end end
end end

View file

@ -1055,53 +1055,38 @@ def empty_object(conn, _) do
def render_notification(user, %{id: id, activity: activity, inserted_at: created_at} = _params) 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"]) 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 = response = %{
NaiveDateTime.to_iso8601(created_at) id: to_string(id),
|> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) type: mastodon_type,
created_at: CommonAPI.Utils.to_masto_date(created_at),
id = id |> to_string
case activity.data["type"] do
"Create" ->
%{
id: id,
type: "mention",
created_at: created_at,
account: AccountView.render("account.json", %{user: actor, for: user}),
status: StatusView.render("status.json", %{activity: activity, for: user})
}
"Like" ->
liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
%{
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})
}
"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}) 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})
})
"favourite" ->
response
|> Map.merge(%{
status: StatusView.render("status.json", %{activity: parent_activity, for: user})
})
"reblog" ->
response
|> Map.merge(%{
status: StatusView.render("status.json", %{activity: parent_activity, for: user})
})
"follow" ->
response
_ -> _ ->
nil nil
end end
@ -1167,6 +1152,7 @@ def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
end end
def create_push_subscription(%{assigns: %{user: user, token: token}} = conn, params) do 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) Pleroma.Web.Push.Subscription.delete_if_exists(user, token)
{:ok, subscription} = Pleroma.Web.Push.Subscription.create(user, token, params) {:ok, subscription} = Pleroma.Web.Push.Subscription.create(user, token, params)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription) view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
@ -1174,6 +1160,7 @@ def create_push_subscription(%{assigns: %{user: user, token: token}} = conn, par
end end
def get_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do 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) subscription = Pleroma.Web.Push.Subscription.get(user, token)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription) view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
json(conn, view) json(conn, view)
@ -1183,12 +1170,14 @@ def update_push_subscription(
%{assigns: %{user: user, token: token}} = conn, %{assigns: %{user: user, token: token}} = conn,
params params
) do ) do
true = Pleroma.Web.Push.enabled()
{:ok, subscription} = Pleroma.Web.Push.Subscription.update(user, token, params) {:ok, subscription} = Pleroma.Web.Push.Subscription.update(user, token, params)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription) view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
json(conn, view) json(conn, view)
end end
def delete_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do 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) {:ok, _response} = Pleroma.Web.Push.Subscription.delete(user, token)
json(conn, %{}) json(conn, %{})
end end

View file

@ -5,7 +5,12 @@ def render("push_subscription.json", %{subscription: subscription}) do
%{ %{
id: to_string(subscription.id), id: to_string(subscription.id),
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
alerts: Map.get(subscription.data, "alerts") alerts: Map.get(subscription.data, "alerts"),
server_key: server_key()
} }
end end
defp server_key do
Keyword.get(Application.get_env(:web_push_encryption, :vapid_details), :public_key)
end
end end

View file

@ -9,67 +9,99 @@ defmodule Pleroma.Web.Push do
@types ["Create", "Follow", "Announce", "Like"] @types ["Create", "Follow", "Announce", "Like"]
@gcm_api_key nil
def start_link() do def start_link() do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__) GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end end
def init(:ok) do def vapid_config() do
case Application.get_env(:web_push_encryption, :vapid_details) do Application.get_env(:web_push_encryption, :vapid_details, [])
nil -> end
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 enabled() do
case vapid_config() do
_ -> [] -> false
{:ok, %{}} list when is_list(list) -> true
_ -> false
end end
end end
def send(notification) do def send(notification) do
if Application.get_env(:web_push_encryption, :vapid_details) do if enabled() do
GenServer.cast(Pleroma.Web.Push, {:send, notification}) GenServer.cast(Pleroma.Web.Push, {:send, notification})
end end
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( def handle_cast(
{:send, %{activity: %{data: %{"type" => type}}, user_id: user_id} = notification}, {:send, %{activity: %{data: %{"type" => type}}, user_id: user_id} = notification},
state state
) )
when type in @types do when type in @types do
actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) 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 Subscription
|> where(user_id: ^user_id) |> where(user_id: ^user_id)
|> preload(:token)
|> Repo.all() |> Repo.all()
|> Enum.each(fn record -> |> Enum.filter(fn subscription ->
subscription = %{ get_in(subscription.data, ["alerts", type]) || false
end)
|> Enum.each(fn subscription ->
sub = %{
keys: %{ keys: %{
p256dh: record.key_p256dh, p256dh: subscription.key_p256dh,
auth: record.key_auth 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 -> {:ok, %{status_code: code}} when 400 <= code and code < 500 ->
Logger.debug("Removing subscription record") Logger.debug("Removing subscription record")
Repo.delete!(record) Repo.delete!(subscription)
:ok :ok
{:ok, %{status_code: code}} when 200 <= code and code < 300 -> {:ok, %{status_code: code}} when 200 <= code and code < 300 ->
:ok :ok
{:ok, %{status_code: code}} -> {:ok, %{status_code: code}} ->
Logger.error("Web Push Nonification failed with code: #{code}") Logger.error("Web Push Notification failed with code: #{code}")
:error :error
_ -> _ ->
Logger.error("Web Push Nonification failed with unknown error") Logger.error("Web Push Notification failed with unknown error")
:error :error
end end
end) end)
@ -82,35 +114,21 @@ def handle_cast({:send, _}, state) do
{:noreply, state} {:noreply, state}
end end
def format(%{activity: %{data: %{"type" => "Create"}}}, actor) do defp format_title(%{activity: %{data: %{"type" => type}}}) do
%{ case type do
title: "New Mention", "Create" -> "New Mention"
body: "@#{actor.nickname} has mentiond you", "Follow" -> "New Follower"
icon: User.avatar_url(actor) "Announce" -> "New Repeat"
} "Like" -> "New Favorite"
end
end end
def format(%{activity: %{data: %{"type" => "Follow"}}}, actor) do defp format_body(%{activity: %{data: %{"type" => type}}}, actor) do
%{ case type do
title: "New Follower", "Create" -> "@#{actor.nickname} has mentioned you"
body: "@#{actor.nickname} has followed you", "Follow" -> "@#{actor.nickname} has followed you"
icon: User.avatar_url(actor) "Announce" -> "@#{actor.nickname} has repeated your post"
} "Like" -> "@#{actor.nickname} has favorited your post"
end end
def format(%{activity: %{data: %{"type" => "Announce"}}}, actor) do
%{
title: "New Announce",
body: "@#{actor.nickname} has announced your post",
icon: User.avatar_url(actor)
}
end
def format(%{activity: %{data: %{"type" => "Like"}}}, actor) do
%{
title: "New Like",
body: "@#{actor.nickname} has liked your post",
icon: User.avatar_url(actor)
}
end end
end end

View file

@ -37,8 +37,8 @@ def create(
user_id: user.id, user_id: user.id,
token_id: token.id, token_id: token.id,
endpoint: endpoint, endpoint: endpoint,
key_auth: key_auth, key_auth: ensure_base64_urlsafe(key_auth),
key_p256dh: key_p256dh, key_p256dh: ensure_base64_urlsafe(key_p256dh),
data: alerts(params) data: alerts(params)
}) })
end end
@ -63,4 +63,14 @@ def delete_if_exists(user, token) do
sub -> Repo.delete(sub) sub -> Repo.delete(sub)
end end
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 end

View file

@ -156,8 +156,7 @@ def config(conn, _params) do
|> send_resp(200, response) |> send_resp(200, response)
_ -> _ ->
vapid_public_key = vapid_public_key = Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
Keyword.get(Application.get_env(:web_push_encryption, :vapid_details), :public_key)
uploadlimit = %{ uploadlimit = %{
uploadlimit: to_string(Keyword.get(instance, :upload_limit)), uploadlimit: to_string(Keyword.get(instance, :upload_limit)),