diff --git a/docker/.env.example b/docker/.env.example index 8c8054d..55d4d0c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml index 3bac0fd..d346d48 100644 --- a/docker/docker-compose.stage.yml +++ b/docker/docker-compose.stage.yml @@ -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:-} diff --git a/include/records.hrl b/include/records.hrl index 6dc121b..f5407d9 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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), diff --git a/src/core/core_push_subscription.erl b/src/core/core_push_subscription.erl new file mode 100644 index 0000000..b4e2ece --- /dev/null +++ b/src/core/core_push_subscription.erl @@ -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. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 5e0a709..e425059 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -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, []}, diff --git a/src/handlers/handler_notification_prefs.erl b/src/handlers/handler_notification_prefs.erl new file mode 100644 index 0000000..f429a29 --- /dev/null +++ b/src/handlers/handler_notification_prefs.erl @@ -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. diff --git a/src/handlers/handler_push.erl b/src/handlers/handler_push.erl new file mode 100644 index 0000000..58945d2 --- /dev/null +++ b/src/handlers/handler_push.erl @@ -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. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 5417a71..43d7c58 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -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), diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 11ebe19..9214d53 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -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}}) -> diff --git a/src/logic/logic_notification_prefs.erl b/src/logic/logic_notification_prefs.erl new file mode 100644 index 0000000..7fada4a --- /dev/null +++ b/src/logic/logic_notification_prefs.erl @@ -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. diff --git a/src/logic/logic_waitlist.erl b/src/logic/logic_waitlist.erl index 9558f6d..561e62d 100644 --- a/src/logic/logic_waitlist.erl +++ b/src/logic/logic_waitlist.erl @@ -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. %%%=================================================================== diff --git a/src/logic/logic_web_push.erl b/src/logic/logic_web_push.erl new file mode 100644 index 0000000..1789daa --- /dev/null +++ b/src/logic/logic_web_push.erl @@ -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 = <>, + {Cipher, Tag} = crypto:crypto_one_time_aead(aes_128_gcm, Cek, Nonce, Plain, <<>>, true), + Ciphertext = <>, + Rs = 4096, + IdLen = byte_size(AsPublic), + Body = <>, + {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, <>), + hkdf_expand(Prk, Info, Len, I + 1, <>, 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)). diff --git a/src/migrations/20260815200000_push_subscription.erl b/src/migrations/20260815200000_push_subscription.erl new file mode 100644 index 0000000..9d4502f --- /dev/null +++ b/src/migrations/20260815200000_push_subscription.erl @@ -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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 25aac67..188e73d 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -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 diff --git a/test/unit/logic_notification_prefs_tests.erl b/test/unit/logic_notification_prefs_tests.erl new file mode 100644 index 0000000..5c31334 --- /dev/null +++ b/test/unit/logic_notification_prefs_tests.erl @@ -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()).