feat(notify): Web Push subscriptions and email/push prefs.
Prefs in user.preferences; push_subscription table; VAPID send on reminder/waitlist. Refs EventHub/EventHubBack#75
This commit is contained in:
@@ -26,6 +26,10 @@ EMAIL_TRANSPORT=auto
|
||||
EMAIL_API_KEY=
|
||||
EMAIL_API_PROVIDER=resend
|
||||
# EMAIL_API_URL=https://api.resend.com/emails
|
||||
# Web Push (VAPID). Generate: npx web-push generate-vapid-keys
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@calentiq.com
|
||||
# SMTP (пусто SMTP_HOST + нет API key = только лог; пароль не коммитить)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
|
||||
@@ -45,6 +45,9 @@ services:
|
||||
- EMAIL_API_KEY=${EMAIL_API_KEY:-}
|
||||
- EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend}
|
||||
- EMAIL_API_URL=${EMAIL_API_URL:-}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@calentiq.com}
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
|
||||
@@ -336,6 +336,17 @@
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Web Push subscription (Back#75). Prefs live in user.preferences:
|
||||
%% notify_email / notify_push (boolean, default true).
|
||||
-record(push_subscription, {
|
||||
id :: binary(),
|
||||
user_id :: binary(),
|
||||
endpoint :: binary(),
|
||||
p256dh :: binary(),
|
||||
auth :: binary(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Upsert-счётчики админ-статистики (абсолютные значения).
|
||||
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
|
||||
%% users_total-подобные totals не храним (table_info),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Web Push subscriptions (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_push_subscription).
|
||||
-export([upsert/4, list_by_user/1, delete_by_endpoint/2, delete_by_id/1,
|
||||
get_by_endpoint/1]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec upsert(binary(), binary(), binary(), binary()) ->
|
||||
{ok, #push_subscription{}} | {error, term()}.
|
||||
upsert(UserId, Endpoint, P256dh, Auth) ->
|
||||
Now = calendar:universal_time(),
|
||||
case get_by_endpoint(Endpoint) of
|
||||
{ok, #push_subscription{} = Existing} ->
|
||||
Rec = Existing#push_subscription{
|
||||
user_id = UserId,
|
||||
p256dh = P256dh,
|
||||
auth = Auth,
|
||||
created_at = Now
|
||||
},
|
||||
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
|
||||
{atomic, ok} -> {ok, Rec};
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
Rec = #push_subscription{
|
||||
id = infra_utils:generate_id(16),
|
||||
user_id = UserId,
|
||||
endpoint = Endpoint,
|
||||
p256dh = P256dh,
|
||||
auth = Auth,
|
||||
created_at = Now
|
||||
},
|
||||
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
|
||||
{atomic, ok} -> {ok, Rec};
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec list_by_user(binary()) -> [#push_subscription{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#push_subscription{user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec get_by_endpoint(binary()) -> {ok, #push_subscription{}} | {error, not_found}.
|
||||
get_by_endpoint(Endpoint) ->
|
||||
case mnesia:dirty_index_read(push_subscription, Endpoint, #push_subscription.endpoint) of
|
||||
[Rec | _] -> {ok, Rec};
|
||||
[] ->
|
||||
case mnesia:dirty_match_object(#push_subscription{endpoint = Endpoint, _ = '_'}) of
|
||||
[Rec | _] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec delete_by_endpoint(binary(), binary()) -> ok | {error, not_found | forbidden}.
|
||||
delete_by_endpoint(UserId, Endpoint) ->
|
||||
case get_by_endpoint(Endpoint) of
|
||||
{ok, #push_subscription{id = Id, user_id = UserId}} ->
|
||||
delete_by_id(Id);
|
||||
{ok, #push_subscription{}} ->
|
||||
{error, forbidden};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec delete_by_id(binary()) -> ok | {error, term()}.
|
||||
delete_by_id(Id) ->
|
||||
case mnesia:transaction(fun() -> mnesia:delete({push_subscription, Id}) end) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
@@ -93,6 +93,9 @@ start_http() ->
|
||||
{"/v1/logout", handler_logout, []},
|
||||
{"/v1/user/me", handler_user_me, []},
|
||||
{"/v1/user/me/avatar", handler_user_avatar, []},
|
||||
{"/v1/notifications/prefs", handler_notification_prefs, []},
|
||||
{"/v1/push/vapid-public-key", handler_push, []},
|
||||
{"/v1/push/subscriptions", handler_push, []},
|
||||
{"/v1/user/bookings", handler_user_bookings, []},
|
||||
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
||||
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET/PUT /v1/notifications/prefs (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_notification_prefs).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case Method of
|
||||
<<"GET">> ->
|
||||
case logic_notification_prefs:get_prefs(UserId) of
|
||||
{ok, Prefs} ->
|
||||
handler_utils:send_json(Req1, 200, Prefs);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
<<"PUT">> ->
|
||||
put_prefs(Req1, UserId);
|
||||
_ ->
|
||||
cowboy_req:reply(405, #{}, <<>>, Req1)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end,
|
||||
{ok, Req, Opts}.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/notifications/prefs">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get notification channel prefs (email/push)">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Prefs">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/notifications/prefs">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update notification channel prefs">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated prefs">>},
|
||||
400 => #{description => <<"Invalid body">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
put_prefs(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
case {maps:get(<<"email">>, Map, undefined), maps:get(<<"push">>, Map, undefined)} of
|
||||
{Email, Push} when is_boolean(Email), is_boolean(Push) ->
|
||||
case logic_notification_prefs:put_prefs(UserId, #{email => Email, push => Push}) of
|
||||
{ok, Prefs} ->
|
||||
handler_utils:send_json(Req1, 200, Prefs);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Update failed">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"email and push booleans required">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
@@ -0,0 +1,112 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Web Push VAPID key + subscriptions (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_push).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
Path = cowboy_req:path(Req),
|
||||
Method = cowboy_req:method(Req),
|
||||
case Path of
|
||||
<<"/v1/push/vapid-public-key">> ->
|
||||
case logic_web_push:vapid_public_key() of
|
||||
{ok, Key} ->
|
||||
handler_utils:send_json(Req, 200, #{publicKey => Key});
|
||||
{error, missing} ->
|
||||
handler_utils:send_error(Req, 503, <<"VAPID not configured">>)
|
||||
end;
|
||||
<<"/v1/push/subscriptions">> ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case Method of
|
||||
<<"POST">> -> post_sub(Req1, UserId);
|
||||
<<"DELETE">> -> delete_sub(Req1, UserId);
|
||||
_ -> cowboy_req:reply(405, #{}, <<>>, Req1)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end;
|
||||
_ ->
|
||||
cowboy_req:reply(404, #{}, <<>>, Req)
|
||||
end,
|
||||
{ok, Req, Opts}.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/push/vapid-public-key">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"VAPID public key for Web Push subscribe">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{200 => #{description => <<"OK">>}, 503 => #{description => <<"Not configured">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/push/subscriptions">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Register Web Push subscription">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{200 => #{description => <<"OK">>}, 401 => #{description => <<"Unauthorized">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/push/subscriptions">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Remove Web Push subscription">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{204 => #{description => <<"Deleted">>}, 401 => #{description => <<"Unauthorized">>}}
|
||||
}
|
||||
].
|
||||
|
||||
post_sub(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Endpoint = maps:get(<<"endpoint">>, Map, undefined),
|
||||
Keys = maps:get(<<"keys">>, Map, #{}),
|
||||
P256 = maps:get(<<"p256dh">>, Keys, undefined),
|
||||
Auth = maps:get(<<"auth">>, Keys, undefined),
|
||||
case {Endpoint, P256, Auth} of
|
||||
{E, P, A} when is_binary(E), E =/= <<>>,
|
||||
is_binary(P), P =/= <<>>,
|
||||
is_binary(A), A =/= <<>> ->
|
||||
case core_push_subscription:upsert(UserId, E, P, A) of
|
||||
{ok, Rec} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
id => Rec#push_subscription.id,
|
||||
endpoint => Rec#push_subscription.endpoint
|
||||
});
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Save failed">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"endpoint and keys.p256dh/auth required">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
|
||||
delete_sub(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Endpoint = maps:get(<<"endpoint">>, Map, <<>>),
|
||||
case Endpoint of
|
||||
<<>> ->
|
||||
handler_utils:send_error(Req1, 400, <<"endpoint required">>);
|
||||
Ep ->
|
||||
_ = core_push_subscription:delete_by_endpoint(UserId, Ep),
|
||||
cowboy_req:reply(204, #{}, <<>>, Req1)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
@@ -19,7 +19,7 @@
|
||||
booking, waitlist_entry,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification,
|
||||
admin_audit, notification, push_subscription,
|
||||
stats_counter, stats_daily, node_metric, schema_migration
|
||||
]).
|
||||
|
||||
@@ -337,6 +337,7 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
||||
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
||||
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||
table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
|
||||
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
@@ -385,6 +386,8 @@ create_indices() ->
|
||||
mnesia:add_table_index(password_reset, user_id),
|
||||
mnesia:add_table_index(notification, user_id),
|
||||
mnesia:add_table_index(notification, is_read),
|
||||
mnesia:add_table_index(push_subscription, user_id),
|
||||
mnesia:add_table_index(push_subscription, endpoint),
|
||||
mnesia:add_table_index(auth_session, family_id),
|
||||
mnesia:add_table_index(auth_session, subject_id),
|
||||
mnesia:add_table_index(report, resolved_by),
|
||||
|
||||
@@ -422,10 +422,15 @@ maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
|
||||
send_reminder(UserId, Title, Start, CalId, EventId) ->
|
||||
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
|
||||
WhenText = format_when(Start),
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
|
||||
_ ->
|
||||
case logic_notification_prefs:email_enabled(UserId) of
|
||||
true ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end,
|
||||
_ = logic_notification:notify_event_reminder(UserId, #{
|
||||
@@ -435,6 +440,12 @@ send_reminder(UserId, Title, Start, CalId, EventId) ->
|
||||
start_time => Start,
|
||||
when_text => WhenText
|
||||
}),
|
||||
_ = logic_web_push:send(
|
||||
UserId,
|
||||
<<"Напоминание о записи"/utf8>>,
|
||||
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
|
||||
Path
|
||||
),
|
||||
ok.
|
||||
|
||||
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Notification channel prefs in user.preferences (Back#75).
|
||||
%%% Keys: notify_email, notify_push (boolean; missing → true).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_notification_prefs).
|
||||
-export([get_prefs/1, put_prefs/2, email_enabled/1, push_enabled/1]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec get_prefs(binary()) -> {ok, #{email := boolean(), push := boolean()}} | {error, not_found}.
|
||||
get_prefs(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{preferences = Prefs}} ->
|
||||
{ok, #{
|
||||
email => pref_bool(Prefs, <<"notify_email">>, true),
|
||||
push => pref_bool(Prefs, <<"notify_push">>, true)
|
||||
}};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
-spec put_prefs(binary(), #{email := boolean(), push := boolean()}) ->
|
||||
{ok, #{email := boolean(), push := boolean()}} | {error, term()}.
|
||||
put_prefs(UserId, #{email := Email, push := Push})
|
||||
when is_boolean(Email), is_boolean(Push) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{preferences = Prefs0}} ->
|
||||
Prefs1 = ensure_map(Prefs0),
|
||||
Prefs2 = Prefs1#{
|
||||
<<"notify_email">> => Email,
|
||||
<<"notify_push">> => Push
|
||||
},
|
||||
case core_user:update(UserId, [{preferences, Prefs2}]) of
|
||||
{ok, _} -> {ok, #{email => Email, push => Push}};
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
-spec email_enabled(binary()) -> boolean().
|
||||
email_enabled(UserId) ->
|
||||
try
|
||||
case get_prefs(UserId) of
|
||||
{ok, #{email := V}} -> V;
|
||||
_ -> true
|
||||
end
|
||||
catch
|
||||
_:_ -> true
|
||||
end.
|
||||
|
||||
-spec push_enabled(binary()) -> boolean().
|
||||
push_enabled(UserId) ->
|
||||
try
|
||||
case get_prefs(UserId) of
|
||||
{ok, #{push := V}} -> V;
|
||||
_ -> true
|
||||
end
|
||||
catch
|
||||
_:_ -> true
|
||||
end.
|
||||
|
||||
ensure_map(M) when is_map(M) -> M;
|
||||
ensure_map(_) -> #{}.
|
||||
|
||||
pref_bool(Prefs, Key, Default) when is_map(Prefs) ->
|
||||
case maps:get(Key, Prefs, Default) of
|
||||
true -> true;
|
||||
false -> false;
|
||||
<<"true">> -> true;
|
||||
<<"false">> -> false;
|
||||
1 -> true;
|
||||
0 -> false;
|
||||
_ -> Default
|
||||
end;
|
||||
pref_bool(_, _, Default) ->
|
||||
Default.
|
||||
@@ -208,10 +208,15 @@ initial_status(_) -> pending.
|
||||
notify_promoted(UserId, Event, Booking) ->
|
||||
Title = Event#event.title,
|
||||
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
|
||||
_ ->
|
||||
case logic_notification_prefs:email_enabled(UserId) of
|
||||
true ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end,
|
||||
_ = logic_notification:notify_waitlist_promoted(UserId, #{
|
||||
@@ -221,6 +226,12 @@ notify_promoted(UserId, Event, Booking) ->
|
||||
title => Title,
|
||||
status => Booking#booking.status
|
||||
}),
|
||||
_ = logic_web_push:send(
|
||||
UserId,
|
||||
<<"Место из листа ожидания"/utf8>>,
|
||||
iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
|
||||
Path
|
||||
),
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Web Push send (VAPID + aes128gcm). Back#75.
|
||||
%%% Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (base64url), VAPID_SUBJECT.
|
||||
%%% Tests may set {eventhub, web_push_deliver} fun/4 mock.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_web_push).
|
||||
-export([send/4, vapid_public_key/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec vapid_public_key() -> {ok, binary()} | {error, missing}.
|
||||
vapid_public_key() ->
|
||||
case env_bin("VAPID_PUBLIC_KEY") of
|
||||
<<>> -> {error, missing};
|
||||
Key -> {ok, Key}
|
||||
end.
|
||||
|
||||
-spec send(binary(), binary(), binary(), binary()) -> ok.
|
||||
send(UserId, Title, Body, Url) ->
|
||||
case logic_notification_prefs:push_enabled(UserId) of
|
||||
false ->
|
||||
ok;
|
||||
true ->
|
||||
case {env_bin("VAPID_PUBLIC_KEY"), env_bin("VAPID_PRIVATE_KEY")} of
|
||||
{<<>>, _} ->
|
||||
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
|
||||
ok;
|
||||
{_, <<>>} ->
|
||||
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
|
||||
ok;
|
||||
{Pub, Priv} ->
|
||||
Payload = jsx:encode(#{
|
||||
<<"title">> => Title,
|
||||
<<"body">> => Body,
|
||||
<<"url">> => Url
|
||||
}),
|
||||
lists:foreach(
|
||||
fun(Sub) -> safe_push(Sub, Pub, Priv, Payload) end,
|
||||
core_push_subscription:list_by_user(UserId)
|
||||
),
|
||||
ok
|
||||
end
|
||||
end.
|
||||
|
||||
safe_push(#push_subscription{endpoint = Ep, p256dh = P256, auth = Auth} = Sub,
|
||||
VapidPub, VapidPriv, Payload) ->
|
||||
Fun = deliver_fun(),
|
||||
try Fun(Ep, P256, Auth, Payload, VapidPub, VapidPriv) of
|
||||
ok -> ok;
|
||||
{ok, _} -> ok;
|
||||
{error, gone} ->
|
||||
_ = core_push_subscription:delete_by_id(Sub#push_subscription.id),
|
||||
ok;
|
||||
{error, Reason} ->
|
||||
logger:warning(#{what => web_push_error, endpoint => Ep, reason => Reason}),
|
||||
ok;
|
||||
Other ->
|
||||
logger:warning(#{what => web_push_unexpected, endpoint => Ep, result => Other}),
|
||||
ok
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
logger:warning(#{what => web_push_crash, endpoint => Ep,
|
||||
class => Class, reason => Reason, stack => Stack}),
|
||||
ok
|
||||
end.
|
||||
|
||||
deliver_fun() ->
|
||||
case application:get_env(eventhub, web_push_deliver) of
|
||||
{ok, Fun} when is_function(Fun, 6) -> Fun;
|
||||
_ -> fun default_deliver/6
|
||||
end.
|
||||
|
||||
default_deliver(Endpoint, P256dhB64, AuthB64, Payload, VapidPub, VapidPriv) ->
|
||||
_ = application:ensure_all_started(inets),
|
||||
_ = application:ensure_all_started(ssl),
|
||||
case encrypt_aes128gcm(P256dhB64, AuthB64, Payload) of
|
||||
{error, Reason} ->
|
||||
{error, Reason};
|
||||
{ok, Body, CryptoHeaders} ->
|
||||
case vapid_auth_header(Endpoint, VapidPub, VapidPriv) of
|
||||
{error, Reason} ->
|
||||
{error, Reason};
|
||||
{ok, Authz} ->
|
||||
Headers = [
|
||||
{"authorization", Authz},
|
||||
{"ttl", "86400"},
|
||||
{"content-encoding", "aes128gcm"},
|
||||
{"content-type", "application/octet-stream"}
|
||||
| CryptoHeaders
|
||||
],
|
||||
Request = {binary_to_list(Endpoint), Headers, "application/octet-stream", Body},
|
||||
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
|
||||
{cacerts, public_key:cacerts_get()}]}],
|
||||
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
|
||||
{ok, {{_, Code, _}, _, _}} when Code >= 200, Code < 300 ->
|
||||
ok;
|
||||
{ok, {{_, Code, _}, _, _}} when Code =:= 404; Code =:= 410 ->
|
||||
{error, gone};
|
||||
{ok, {{_, Code, _}, _, Resp}} ->
|
||||
{error, {http_status, Code, Resp}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% VAPID (RFC 8292) via jose ES256
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
vapid_auth_header(Endpoint, PubB64, PrivB64) ->
|
||||
try
|
||||
Audience = audience(Endpoint),
|
||||
Sub = case env_bin("VAPID_SUBJECT") of
|
||||
<<>> -> <<"mailto:admin@calentiq.com">>;
|
||||
S -> S
|
||||
end,
|
||||
Exp = erlang:system_time(second) + 12 * 3600,
|
||||
Claims = #{
|
||||
<<"aud">> => Audience,
|
||||
<<"exp">> => Exp,
|
||||
<<"sub">> => Sub
|
||||
},
|
||||
JWK = vapid_jwk(PubB64, PrivB64),
|
||||
{_, TokenBin} = jose_jwt:sign(JWK, #{<<"alg">> => <<"ES256">>}, Claims),
|
||||
Compact = jose_jws:compact(TokenBin),
|
||||
Token = iolist_to_binary(Compact),
|
||||
H = <<"vapid t=", Token/binary, ", k=", PubB64/binary>>,
|
||||
{ok, binary_to_list(H)}
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
{error, {vapid, Class, Reason, Stack}}
|
||||
end.
|
||||
|
||||
audience(Endpoint) ->
|
||||
%% scheme://host[:port]
|
||||
case uri_string:parse(Endpoint) of
|
||||
#{scheme := Scheme, host := Host} = U ->
|
||||
Port = maps:get(port, U, undefined),
|
||||
case Port of
|
||||
undefined ->
|
||||
iolist_to_binary([Scheme, <<"://">>, Host]);
|
||||
P ->
|
||||
iolist_to_binary([Scheme, <<"://">>, Host, <<":">>, integer_to_binary(P)])
|
||||
end;
|
||||
_ ->
|
||||
Endpoint
|
||||
end.
|
||||
|
||||
vapid_jwk(PubB64, PrivB64) ->
|
||||
Pub = b64url_decode(PubB64),
|
||||
Priv = b64url_decode(PrivB64),
|
||||
<<16#04, X:32/binary, Y:32/binary>> = Pub,
|
||||
jose_jwk:from_map(#{
|
||||
<<"kty">> => <<"EC">>,
|
||||
<<"crv">> => <<"P-256">>,
|
||||
<<"x">> => b64url_encode(X),
|
||||
<<"y">> => b64url_encode(Y),
|
||||
<<"d">> => b64url_encode(Priv)
|
||||
}).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% RFC 8291 aes128gcm (simplified single-record)
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
encrypt_aes128gcm(P256dhB64, AuthB64, Payload) ->
|
||||
try
|
||||
UaPublic = b64url_decode(P256dhB64),
|
||||
AuthSecret = b64url_decode(AuthB64),
|
||||
{AsPublic, AsPrivate} = crypto:generate_key(ecdh, secp256r1),
|
||||
Shared = crypto:compute_key(ecdh, UaPublic, AsPrivate, secp256r1),
|
||||
%% ikm
|
||||
AuthInfo = <<"WebPush: info", 0, UaPublic/binary, AsPublic/binary>>,
|
||||
Ikm = hkdf(AuthSecret, Shared, AuthInfo, 32),
|
||||
Salt = crypto:strong_rand_bytes(16),
|
||||
%% cek + nonce
|
||||
KeyInfo = <<"Content-Encoding: aes128gcm", 0>>,
|
||||
NonceInfo = <<"Content-Encoding: nonce", 0>>,
|
||||
Cek = hkdf(Salt, Ikm, KeyInfo, 16),
|
||||
Nonce = hkdf(Salt, Ikm, NonceInfo, 12),
|
||||
%% pad: payload || 0x02 (delimiter) for aes128gcm
|
||||
Plain = <<Payload/binary, 2>>,
|
||||
{Cipher, Tag} = crypto:crypto_one_time_aead(aes_128_gcm, Cek, Nonce, Plain, <<>>, true),
|
||||
Ciphertext = <<Cipher/binary, Tag/binary>>,
|
||||
Rs = 4096,
|
||||
IdLen = byte_size(AsPublic),
|
||||
Body = <<Salt/binary, Rs:32, IdLen:8, AsPublic/binary, Ciphertext/binary>>,
|
||||
{ok, Body, []}
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
{error, {encrypt, Class, Reason, Stack}}
|
||||
end.
|
||||
|
||||
%% HKDF-Extract + Expand (SHA-256), RFC 5869
|
||||
hkdf(Salt, Ikm, Info, Len) ->
|
||||
Prk = crypto:mac(hmac, sha256, Salt, Ikm),
|
||||
hkdf_expand(Prk, Info, Len).
|
||||
|
||||
hkdf_expand(Prk, Info, Len) ->
|
||||
hkdf_expand(Prk, Info, Len, 1, <<>>, <<>>).
|
||||
|
||||
hkdf_expand(_Prk, _Info, Len, _I, Acc, _Prev) when byte_size(Acc) >= Len ->
|
||||
binary:part(Acc, 0, Len);
|
||||
hkdf_expand(Prk, Info, Len, I, Acc, Prev) ->
|
||||
T = crypto:mac(hmac, sha256, Prk, <<Prev/binary, Info/binary, I:8>>),
|
||||
hkdf_expand(Prk, Info, Len, I + 1, <<Acc/binary, T/binary>>, T).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% helpers
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
env_bin("VAPID_PUBLIC_KEY") ->
|
||||
app_or_os(vapid_public_key, "VAPID_PUBLIC_KEY");
|
||||
env_bin("VAPID_PRIVATE_KEY") ->
|
||||
app_or_os(vapid_private_key, "VAPID_PRIVATE_KEY");
|
||||
env_bin("VAPID_SUBJECT") ->
|
||||
app_or_os(vapid_subject, "VAPID_SUBJECT");
|
||||
env_bin(Name) ->
|
||||
case os:getenv(Name) of
|
||||
false -> <<>>;
|
||||
"" -> <<>>;
|
||||
S -> unicode:characters_to_binary(S)
|
||||
end.
|
||||
|
||||
app_or_os(AppKey, EnvName) ->
|
||||
case application:get_env(eventhub, AppKey) of
|
||||
{ok, B} when is_binary(B), B =/= <<>> -> B;
|
||||
{ok, L} when is_list(L), L =/= "" -> unicode:characters_to_binary(L);
|
||||
_ ->
|
||||
case os:getenv(EnvName) of
|
||||
false -> <<>>;
|
||||
"" -> <<>>;
|
||||
S -> unicode:characters_to_binary(S)
|
||||
end
|
||||
end.
|
||||
|
||||
b64url_decode(Bin) when is_binary(Bin) ->
|
||||
S0 = binary_to_list(Bin),
|
||||
S1 = lists:map(fun($-) -> $+; ($_) -> $/; (C) -> C end, S0),
|
||||
Pad = case length(S1) rem 4 of
|
||||
0 -> "";
|
||||
2 -> "==";
|
||||
3 -> "=";
|
||||
1 -> "="
|
||||
end,
|
||||
base64:decode(S1 ++ Pad).
|
||||
|
||||
b64url_encode(Bin) when is_binary(Bin) ->
|
||||
S = base64:encode_to_string(Bin),
|
||||
S2 = lists:filter(fun(C) -> C =/= $= end, S),
|
||||
unicode:characters_to_binary(
|
||||
lists:map(fun($+) -> $-; ($/) -> $_; (C) -> C end, S2)).
|
||||
@@ -0,0 +1,37 @@
|
||||
%% @doc Create push_subscription table (Back#75).
|
||||
-module('20260815200000_push_subscription').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(push_subscription, record_info(fields, push_subscription)),
|
||||
ensure_index(push_subscription, user_id),
|
||||
ensure_index(push_subscription, endpoint),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(push_subscription),
|
||||
ok.
|
||||
|
||||
ensure_table(Table, Attrs) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_index(Table, Attr) ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end.
|
||||
@@ -102,6 +102,8 @@ user() ->
|
||||
handler_user_following,
|
||||
handler_user_me,
|
||||
handler_user_avatar,
|
||||
handler_notification_prefs,
|
||||
handler_push,
|
||||
handler_calendar_cover,
|
||||
handler_media,
|
||||
handler_user_reviews
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-module(logic_notification_prefs_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
prefs_test_() ->
|
||||
{setup, fun setup/0, fun cleanup/1, [
|
||||
{"defaults true", fun test_defaults/0},
|
||||
{"put and get", fun test_put_get/0},
|
||||
{"web_push no-op without vapid", fun test_push_noop/0}
|
||||
]}.
|
||||
|
||||
setup() ->
|
||||
application:load(eventhub),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
application:unset_env(eventhub, web_push_deliver),
|
||||
application:unset_env(eventhub, vapid_public_key),
|
||||
application:unset_env(eventhub, vapid_private_key),
|
||||
ok.
|
||||
|
||||
test_defaults() ->
|
||||
%% without user in mnesia — email_enabled falls back true
|
||||
?assertEqual(true, logic_notification_prefs:email_enabled(<<"missing-user">>)),
|
||||
?assertEqual(true, logic_notification_prefs:push_enabled(<<"missing-user">>)).
|
||||
|
||||
test_put_get() ->
|
||||
case whereis(mnesia_sup) of
|
||||
undefined ->
|
||||
%% skip if no mnesia in unit env
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
test_push_noop() ->
|
||||
application:unset_env(eventhub, vapid_public_key),
|
||||
application:unset_env(eventhub, vapid_private_key),
|
||||
?assertEqual(ok, logic_web_push:send(<<"u1">>, <<"t">>, <<"b">>, <<"/x">>)),
|
||||
?assertEqual({error, missing}, logic_web_push:vapid_public_key()).
|
||||
Reference in New Issue
Block a user