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,
"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}
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 @@ def get_in_reply_to_activity(%Activity{data: %{"object" => %{"inReplyTo" => ap_i
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

View File

@ -15,10 +15,10 @@ def init(options), do: options
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 @@ def call(conn, _) 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 @@ defp fetch_token_from_session(conn) 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

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
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
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,
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 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
end
@ -1167,6 +1152,7 @@ def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) 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 @@ def create_push_subscription(%{assigns: %{user: user, token: token}} = conn, par
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 @@ def update_push_subscription(
%{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

View File

@ -5,7 +5,12 @@ def render("push_subscription.json", %{subscription: subscription}) 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

View 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"
)
def vapid_config() do
Application.get_env(:web_push_encryption, :vapid_details, [])
end
:ignore
_ ->
{: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 @@ def handle_cast({:send, _}, state) 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)
}
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" => "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)
}
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

View File

@ -37,8 +37,8 @@ def create(
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 @@ def delete_if_exists(user, token) 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

View File

@ -156,8 +156,7 @@ def config(conn, _params) 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)),