feat(notify): Web Push subscriptions and email/push prefs.
CI / test (push) Successful in 7m43s
CI / deploy-ift (push) Successful in 3m33s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Failing after 3m40s
CI / e2e-stage (push) Has been skipped

Prefs in user.preferences; push_subscription table; VAPID send on
reminder/waitlist. Refs EventHub/EventHubBack#75
This commit is contained in:
2026-08-15 20:19:44 +03:00
parent e86d603f30
commit 12274f9ed0
15 changed files with 729 additions and 9 deletions
+4
View File
@@ -26,6 +26,10 @@ EMAIL_TRANSPORT=auto
EMAIL_API_KEY= EMAIL_API_KEY=
EMAIL_API_PROVIDER=resend EMAIL_API_PROVIDER=resend
# EMAIL_API_URL=https://api.resend.com/emails # 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 (пусто SMTP_HOST + нет API key = только лог; пароль не коммитить)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
+3
View File
@@ -45,6 +45,9 @@ services:
- EMAIL_API_KEY=${EMAIL_API_KEY:-} - EMAIL_API_KEY=${EMAIL_API_KEY:-}
- EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend} - EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend}
- EMAIL_API_URL=${EMAIL_API_URL:-} - 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_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-} - SMTP_USER=${SMTP_USER:-}
+11
View File
@@ -336,6 +336,17 @@
created_at :: calendar:datetime() 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-счётчики админ-статистики (абсолютные значения). %% Upsert-счётчики админ-статистики (абсолютные значения).
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()} %% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
%% users_total-подобные totals не храним (table_info), %% users_total-подобные totals не храним (table_info),
+73
View File
@@ -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.
+3
View File
@@ -93,6 +93,9 @@ start_http() ->
{"/v1/logout", handler_logout, []}, {"/v1/logout", handler_logout, []},
{"/v1/user/me", handler_user_me, []}, {"/v1/user/me", handler_user_me, []},
{"/v1/user/me/avatar", handler_user_avatar, []}, {"/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/bookings", handler_user_bookings, []},
{"/v1/user/booking-requests", handler_user_booking_requests, []}, {"/v1/user/booking-requests", handler_user_booking_requests, []},
{"/v1/user/studio-bookings", handler_user_studio_bookings, []}, {"/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.
+112
View File
@@ -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.
+4 -1
View File
@@ -19,7 +19,7 @@
booking, waitlist_entry, booking, waitlist_entry,
review, review_vote, report, banned_word, automod_settings, automod_hit, review, review_vote, report, banned_word, automod_settings, automod_hit,
ticket, subscription, ticket, subscription,
admin_audit, notification, admin_audit, notification, push_subscription,
stats_counter, stats_daily, node_metric, schema_migration 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(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; 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(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_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(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; 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(password_reset, user_id),
mnesia:add_table_index(notification, user_id), mnesia:add_table_index(notification, user_id),
mnesia:add_table_index(notification, is_read), 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, family_id),
mnesia:add_table_index(auth_session, subject_id), mnesia:add_table_index(auth_session, subject_id),
mnesia:add_table_index(report, resolved_by), mnesia:add_table_index(report, resolved_by),
+15 -4
View File
@@ -422,10 +422,15 @@ maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
send_reminder(UserId, Title, Start, CalId, EventId) -> send_reminder(UserId, Title, Start, CalId, EventId) ->
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>, Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
WhenText = format_when(Start), WhenText = format_when(Start),
case core_user:get_by_id(UserId) of case logic_notification_prefs:email_enabled(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> -> true ->
_ = logic_email:send_booking_reminder(Email, Title, Start, Path); 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 ok
end, end,
_ = logic_notification:notify_event_reminder(UserId, #{ _ = logic_notification:notify_event_reminder(UserId, #{
@@ -435,6 +440,12 @@ send_reminder(UserId, Title, Start, CalId, EventId) ->
start_time => Start, start_time => Start,
when_text => WhenText when_text => WhenText
}), }),
_ = logic_web_push:send(
UserId,
<<"Напоминание о записи"/utf8>>,
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
Path
),
ok. ok.
format_when({{Y, Mo, D}, {H, Mi, _S}}) -> format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
+78
View File
@@ -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.
+15 -4
View File
@@ -208,10 +208,15 @@ initial_status(_) -> pending.
notify_promoted(UserId, Event, Booking) -> notify_promoted(UserId, Event, Booking) ->
Title = Event#event.title, Title = Event#event.title,
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>, Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
case core_user:get_by_id(UserId) of case logic_notification_prefs:email_enabled(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> -> true ->
_ = logic_email:send_waitlist_promoted(Email, Title, Path); 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 ok
end, end,
_ = logic_notification:notify_waitlist_promoted(UserId, #{ _ = logic_notification:notify_waitlist_promoted(UserId, #{
@@ -221,6 +226,12 @@ notify_promoted(UserId, Event, Booking) ->
title => Title, title => Title,
status => Booking#booking.status status => Booking#booking.status
}), }),
_ = logic_web_push:send(
UserId,
<<"Место из листа ожидания"/utf8>>,
iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
Path
),
ok. ok.
%%%=================================================================== %%%===================================================================
+253
View File
@@ -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.
+2
View File
@@ -102,6 +102,8 @@ user() ->
handler_user_following, handler_user_following,
handler_user_me, handler_user_me,
handler_user_avatar, handler_user_avatar,
handler_notification_prefs,
handler_push,
handler_calendar_cover, handler_calendar_cover,
handler_media, handler_media,
handler_user_reviews 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()).