initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push).
-behaviour(gen_server).
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([
handle_message_create/1,
sync_user_guild_settings/3,
sync_user_blocked_ids/2,
invalidate_user_badge_count/1
]).
-export([get_cache_stats/0]).
-export_type([state/0]).
-import(push_eligibility, [is_eligible_for_push/8]).
-import(push_cache, [
update_lru/2,
get_user_push_subscriptions/2,
cache_user_subscriptions/3,
invalidate_user_badge_count/2
]).
-import(push_sender, [send_push_notifications/8]).
-import(push_logger_filter, [install_progress_filter/0]).
-type state() :: #{
user_guild_settings_cache := map(),
user_guild_settings_lru := list(),
user_guild_settings_size := non_neg_integer(),
user_guild_settings_max_mb := non_neg_integer() | undefined,
push_subscriptions_cache := map(),
push_subscriptions_lru := list(),
push_subscriptions_size := non_neg_integer(),
push_subscriptions_max_mb := non_neg_integer() | undefined,
blocked_ids_cache := map(),
blocked_ids_lru := list(),
blocked_ids_size := non_neg_integer(),
blocked_ids_max_mb := non_neg_integer() | undefined,
badge_counts_cache := map(),
badge_counts_lru := list(),
badge_counts_size := non_neg_integer(),
badge_counts_max_mb := non_neg_integer() | undefined,
badge_counts_ttl_seconds := non_neg_integer()
}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
init([]) ->
install_progress_filter(),
PushEnabled = fluxer_gateway_env:get(push_enabled),
BaseState = #{
user_guild_settings_cache => #{},
user_guild_settings_lru => [],
user_guild_settings_size => 0,
user_guild_settings_max_mb => undefined,
push_subscriptions_cache => #{},
push_subscriptions_lru => [],
push_subscriptions_size => 0,
push_subscriptions_max_mb => undefined,
blocked_ids_cache => #{},
blocked_ids_lru => [],
blocked_ids_size => 0,
blocked_ids_max_mb => undefined,
badge_counts_cache => #{},
badge_counts_lru => [],
badge_counts_size => 0,
badge_counts_max_mb => undefined,
badge_counts_ttl_seconds => 0
},
case PushEnabled of
true ->
UgsMaxMb = fluxer_gateway_env:get(push_user_guild_settings_cache_mb),
PsMaxMb = fluxer_gateway_env:get(push_subscriptions_cache_mb),
BiMaxMb = fluxer_gateway_env:get(push_blocked_ids_cache_mb),
BcMaxMb = fluxer_gateway_env:get(push_badge_counts_cache_mb),
BcTtl = fluxer_gateway_env:get(push_badge_counts_cache_ttl_seconds),
{ok, BaseState#{
user_guild_settings_max_mb := UgsMaxMb,
push_subscriptions_max_mb := PsMaxMb,
blocked_ids_max_mb := BiMaxMb,
badge_counts_max_mb := BcMaxMb,
badge_counts_ttl_seconds := BcTtl
}};
false ->
{ok, BaseState}
end.
handle_call(get_cache_stats, _From, State) ->
#{
user_guild_settings_cache := UgsCache,
push_subscriptions_cache := PsCache,
blocked_ids_cache := BiCache,
badge_counts_cache := BcCache
} = State,
Stats = #{
user_guild_settings_size => maps:size(UgsCache),
push_subscriptions_size => maps:size(PsCache),
blocked_ids_size => maps:size(BiCache),
badge_counts_size => maps:size(BcCache)
},
{reply, {ok, Stats}, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
handle_cast({handle_message_create, Params}, State) ->
ParamMap = case Params of Maps when is_map(Maps) -> Maps; _ -> #{} end,
GuildId = maps:get(<<"guild_id">>, ParamMap, undefined),
ChannelId = maps:get(<<"channel_id">>, ParamMap, undefined),
MessageId = maps:get(<<"id">>, ParamMap, undefined),
{noreply, do_handle_message_create(Params, State)};
handle_cast({sync_user_guild_settings, UserId, GuildId, UserGuildSettings}, State) ->
#{
user_guild_settings_cache := UgsCache,
user_guild_settings_lru := UgsLru
} = State,
Key = {settings, UserId, GuildId},
NewCache = maps:put(Key, UserGuildSettings, UgsCache),
NewLru = update_lru(Key, UgsLru),
{noreply, State#{
user_guild_settings_cache := NewCache,
user_guild_settings_lru := NewLru
}};
handle_cast({sync_user_blocked_ids, UserId, BlockedIds}, State) ->
#{
blocked_ids_cache := BiCache,
blocked_ids_lru := BiLru
} = State,
Key = {blocked, UserId},
NewCache = maps:put(Key, BlockedIds, BiCache),
NewLru = update_lru(Key, BiLru),
{noreply, State#{
blocked_ids_cache := NewCache,
blocked_ids_lru := NewLru
}};
handle_cast({cache_user_guild_settings, UserId, GuildId, Settings}, State) ->
#{
user_guild_settings_cache := UgsCache,
user_guild_settings_lru := UgsLru
} = State,
Key = {settings, UserId, GuildId},
NewCache = maps:put(Key, Settings, UgsCache),
NewLru = update_lru(Key, UgsLru),
{noreply, State#{
user_guild_settings_cache := NewCache,
user_guild_settings_lru := NewLru
}};
handle_cast({invalidate_user_badge_count, UserId}, State) ->
{noreply, invalidate_user_badge_count(UserId, State)};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, {state, UgsCache, UgsLru, UgsSize, UgsMaxMb, PsCache, PsLru, PsSize, PsMaxMb,
BiCache, BiLru, BiSize, BiMaxMb, BcCache, BcLru, BcSize, BcMaxMb, BcTtl}, _Extra) ->
{ok, #{
user_guild_settings_cache => UgsCache,
user_guild_settings_lru => UgsLru,
user_guild_settings_size => UgsSize,
user_guild_settings_max_mb => UgsMaxMb,
push_subscriptions_cache => PsCache,
push_subscriptions_lru => PsLru,
push_subscriptions_size => PsSize,
push_subscriptions_max_mb => PsMaxMb,
blocked_ids_cache => BiCache,
blocked_ids_lru => BiLru,
blocked_ids_size => BiSize,
blocked_ids_max_mb => BiMaxMb,
badge_counts_cache => BcCache,
badge_counts_lru => BcLru,
badge_counts_size => BcSize,
badge_counts_max_mb => BcMaxMb,
badge_counts_ttl_seconds => BcTtl
}};
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
handle_message_create(Params) ->
PushEnabled = fluxer_gateway_env:get(push_enabled),
case PushEnabled of
true -> gen_server:cast(?MODULE, {handle_message_create, Params});
false ->
ok
end.
sync_user_guild_settings(UserId, GuildId, UserGuildSettings) ->
gen_server:cast(?MODULE, {sync_user_guild_settings, UserId, GuildId, UserGuildSettings}).
sync_user_blocked_ids(UserId, BlockedIds) ->
gen_server:cast(?MODULE, {sync_user_blocked_ids, UserId, BlockedIds}).
invalidate_user_badge_count(UserId) ->
gen_server:cast(?MODULE, {invalidate_user_badge_count, UserId}).
get_cache_stats() ->
gen_server:call(?MODULE, get_cache_stats, 5000).
do_handle_message_create(Params, State) ->
MessageData = maps:get(message_data, Params),
UserIds = maps:get(user_ids, Params),
GuildId = maps:get(guild_id, Params),
AuthorId = maps:get(author_id, Params),
UserRolesMap = maps:get(user_roles, Params, #{}),
ChannelId = binary_to_integer(maps:get(<<"channel_id">>, MessageData)),
MessageId = binary_to_integer(maps:get(<<"id">>, MessageData)),
GuildDefaultNotifications = maps:get(guild_default_notifications, Params, 0),
GuildName = maps:get(guild_name, Params, undefined),
ChannelName = maps:get(channel_name, Params, undefined),
logger:debug(
"[push] Processing message ~p in channel ~p, guild ~p for users ~p (author ~p, defaults ~p)",
[MessageId, ChannelId, GuildId, UserIds, AuthorId, GuildDefaultNotifications]
),
EligibleUsers = lists:filter(
fun(UserId) ->
Eligible = is_eligible_for_push(
UserId,
AuthorId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
),
logger:debug("[push] User ~p eligible: ~p", [UserId, Eligible]),
Eligible
end,
UserIds
),
logger:debug("[push] Eligible users: ~p", [EligibleUsers]),
case EligibleUsers of
[] ->
State;
_ ->
send_push_notifications(
EligibleUsers,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
State
)
end.

View File

@@ -0,0 +1,161 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_cache).
-export([update_lru/2]).
-export([get_user_push_subscriptions/2]).
-export([cache_user_subscriptions/3]).
-export([get_user_badge_count/2]).
-export([cache_user_badge_count/4]).
-export([estimate_subscriptions_size/1]).
-export([evict_if_needed/4]).
-export([invalidate_user_badge_count/2]).
update_lru(Key, Lru) ->
NewLru = lists:delete(Key, Lru),
[Key | NewLru].
get_user_push_subscriptions(UserId, State) ->
Key = {subscriptions, UserId},
PushSubscriptionsCache = maps:get(push_subscriptions_cache, State, #{}),
case maps:get(Key, PushSubscriptionsCache, undefined) of
undefined ->
[];
Subs ->
Subs
end.
cache_user_subscriptions(UserId, Subscriptions, State) ->
Key = {subscriptions, UserId},
NewSubsSize = estimate_subscriptions_size(Subscriptions),
OldSubsSize =
case maps:get(Key, maps:get(push_subscriptions_cache, State, #{}), undefined) of
undefined -> 0;
OldSubs -> estimate_subscriptions_size(OldSubs)
end,
SizeDelta = NewSubsSize - OldSubsSize,
PushSubscriptionsLru = maps:get(push_subscriptions_lru, State, []),
NewLru = update_lru(Key, PushSubscriptionsLru),
PushSubscriptionsCache = maps:get(push_subscriptions_cache, State, #{}),
NewCache = maps:put(Key, Subscriptions, PushSubscriptionsCache),
PushSubscriptionsSize = maps:get(push_subscriptions_size, State, 0),
NewSize = PushSubscriptionsSize + SizeDelta,
MaxBytes =
case maps:get(push_subscriptions_max_mb, State, undefined) of
undefined -> NewSize;
Mb -> Mb * 1024 * 1024
end,
{FinalCache, FinalLru, FinalSize} = evict_if_needed(
NewCache, NewLru, NewSize, MaxBytes
),
State#{
push_subscriptions_cache => FinalCache,
push_subscriptions_lru => FinalLru,
push_subscriptions_size => FinalSize
}.
get_user_badge_count(UserId, State) ->
Key = {badge_count, UserId},
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
case maps:get(Key, BadgeCountsCache, undefined) of
undefined ->
undefined;
Badge ->
Badge
end.
cache_user_badge_count(UserId, BadgeCount, CachedAt, State) ->
Key = {badge_count, UserId},
NewBadge = {BadgeCount, CachedAt},
OldBadgeSize =
case maps:get(Key, maps:get(badge_counts_cache, State, #{}), undefined) of
undefined -> 0;
OldBadge -> estimate_badge_count_size(OldBadge)
end,
NewBadgeSize = estimate_badge_count_size(NewBadge),
SizeDelta = NewBadgeSize - OldBadgeSize,
BadgeCountsLru = maps:get(badge_counts_lru, State, []),
NewLru = update_lru(Key, BadgeCountsLru),
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
NewCache = maps:put(Key, NewBadge, BadgeCountsCache),
BadgeCountsSize = maps:get(badge_counts_size, State, 0),
NewSize = BadgeCountsSize + SizeDelta,
MaxBytes =
case maps:get(badge_counts_max_mb, State, undefined) of
undefined -> NewSize;
Mb -> Mb * 1024 * 1024
end,
{FinalCache, FinalLru, FinalSize} = evict_if_needed(
NewCache, NewLru, NewSize, MaxBytes
),
State#{
badge_counts_cache => FinalCache,
badge_counts_lru => FinalLru,
badge_counts_size => FinalSize
}.
estimate_subscriptions_size(Subscriptions) ->
length(Subscriptions) * 200.
estimate_badge_count_size({_Count, _Timestamp}) ->
64.
evict_if_needed(Cache, Lru, Size, MaxBytes) when Size > MaxBytes ->
evict_oldest(Cache, Lru, Size, MaxBytes, lists:reverse(Lru));
evict_if_needed(Cache, Lru, Size, _MaxBytes) ->
{Cache, Lru, Size}.
evict_oldest(Cache, Lru, Size, _MaxBytes, []) ->
{Cache, Lru, Size};
evict_oldest(Cache, Lru, Size, MaxBytes, [OldestKey | Remaining]) ->
case maps:get(OldestKey, Cache, undefined) of
undefined ->
evict_oldest(Cache, Lru, Size, MaxBytes, Remaining);
OldSubs ->
NewCache = maps:remove(OldestKey, Cache),
NewSize = Size - estimate_subscriptions_size(OldSubs),
NewLru = lists:delete(OldestKey, Lru),
evict_if_needed(NewCache, NewLru, NewSize, MaxBytes)
end.
invalidate_user_badge_count(UserId, State) ->
Key = {badge_count, UserId},
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
case maps:get(Key, BadgeCountsCache, undefined) of
undefined ->
State;
Badge ->
NewCache = maps:remove(Key, BadgeCountsCache),
BadgeCountsLru = lists:delete(Key, maps:get(badge_counts_lru, State, [])),
BadgeCountsSize = maps:get(badge_counts_size, State, 0),
NewSize = max(0, BadgeCountsSize - estimate_badge_count_size(Badge)),
State#{
badge_counts_cache => NewCache,
badge_counts_lru => BadgeCountsLru,
badge_counts_size => NewSize
}
end.

View File

@@ -0,0 +1,37 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_core).
-export([handle_message_create/1]).
-export([sync_user_guild_settings/3]).
-export([sync_user_blocked_ids/2]).
handle_message_create(Params) ->
PushEnabled = fluxer_gateway_env:get(push_enabled),
case PushEnabled of
true ->
gen_server:cast(push, {handle_message_create, Params});
false ->
ok
end.
sync_user_guild_settings(UserId, GuildId, UserGuildSettings) ->
gen_server:cast(push, {sync_user_guild_settings, UserId, GuildId, UserGuildSettings}).
sync_user_blocked_ids(UserId, BlockedIds) ->
gen_server:cast(push, {sync_user_blocked_ids, UserId, BlockedIds}).

View File

@@ -0,0 +1,312 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_eligibility).
-export([is_eligible_for_push/8]).
-export([is_user_blocked/3]).
-export([check_user_guild_settings/7]).
-export([should_allow_notification/5]).
-export([is_user_mentioned/4]).
-define(LARGE_GUILD_THRESHOLD, 250).
-define(LARGE_GUILD_OVERRIDE_FEATURE, <<"LARGE_GUILD_OVERRIDE">>).
-define(MESSAGE_NOTIFICATIONS_NULL, -1).
-define(MESSAGE_NOTIFICATIONS_ALL, 0).
-define(MESSAGE_NOTIFICATIONS_ONLY_MENTIONS, 1).
-define(MESSAGE_NOTIFICATIONS_NO_MESSAGES, 2).
-define(MESSAGE_NOTIFICATIONS_INHERIT, 3).
-define(CHANNEL_TYPE_DM, 1).
-define(CHANNEL_TYPE_GROUP_DM, 3).
is_eligible_for_push(
UserId, UserId, _GuildId, _ChannelId, _MessageData, _GuildDefaultNotifications, _UserRoles, _State
) ->
false;
is_eligible_for_push(
UserId,
AuthorId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
) ->
Blocked = is_user_blocked(UserId, AuthorId, State),
SettingsOk = check_user_guild_settings(
UserId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
),
not Blocked andalso SettingsOk.
is_user_blocked(UserId, AuthorId, State) ->
BlockedIdsCache = maps:get(blocked_ids_cache, State, #{}),
case maps:get({blocked, UserId}, BlockedIdsCache, undefined) of
undefined ->
false;
BlockedIds ->
Blocked = lists:member(AuthorId, BlockedIds),
Blocked
end.
check_user_guild_settings(
_UserId, 0, _ChannelId, _MessageData, _GuildDefaultNotifications, _UserRolesMap, _State
) ->
true;
check_user_guild_settings(
UserId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
) ->
UserGuildSettingsCache = maps:get(user_guild_settings_cache, State, #{}),
Settings =
case maps:get({settings, UserId, GuildId}, UserGuildSettingsCache, undefined) of
undefined ->
FetchedSettings = push_subscriptions:fetch_and_cache_user_guild_settings(
UserId, GuildId, State
),
case FetchedSettings of
null -> #{};
S -> S
end;
S ->
S
end,
MobilePush = maps:get(mobile_push, Settings, true),
case MobilePush of
false ->
false;
true ->
Muted = maps:get(muted, Settings, false),
ChannelOverrides = maps:get(channel_overrides, Settings, #{}),
ChannelKey = integer_to_binary(ChannelId),
ChannelOverride = maps:get(ChannelKey, ChannelOverrides, #{}),
ChannelMuted = maps:get(muted, ChannelOverride, undefined),
ActualMuted =
case ChannelMuted of
undefined -> Muted;
_ -> ChannelMuted
end,
MuteConfig = maps:get(mute_config, Settings, undefined),
IsTempMuted =
case MuteConfig of
undefined ->
false;
#{<<"end_time">> := EndTimeStr} ->
case push_utils:parse_timestamp(EndTimeStr) of
undefined ->
false;
EndTime ->
Now = erlang:system_time(millisecond),
Now < EndTime
end;
_ ->
false
end,
case ActualMuted orelse IsTempMuted of
true ->
false;
false ->
Level = resolve_message_notifications(
ChannelId,
Settings,
GuildDefaultNotifications
),
EffectiveLevel = override_for_large_guild(GuildId, Level, State),
should_allow_notification(
EffectiveLevel,
MessageData,
UserId,
Settings,
UserRolesMap
)
end
end.
should_allow_notification(Level, MessageData, UserId, Settings, UserRolesMap) ->
case Level of
?MESSAGE_NOTIFICATIONS_NO_MESSAGES ->
false;
?MESSAGE_NOTIFICATIONS_ONLY_MENTIONS ->
case is_private_channel(MessageData) of
true ->
true;
false ->
is_user_mentioned(UserId, MessageData, Settings, UserRolesMap)
end;
_ ->
true
end.
is_private_channel(MessageData) ->
ChannelType = maps:get(<<"channel_type">>, MessageData, ?CHANNEL_TYPE_DM),
ChannelType =:= ?CHANNEL_TYPE_DM orelse ChannelType =:= ?CHANNEL_TYPE_GROUP_DM.
is_user_mentioned(UserId, MessageData, Settings, UserRolesMap) ->
MentionEveryone = maps:get(<<"mention_everyone">>, MessageData, false),
SuppressEveryone = maps:get(suppress_everyone, Settings, false),
SuppressRoles = maps:get(suppress_roles, Settings, false),
case {MentionEveryone, SuppressEveryone} of
{true, false} ->
true;
{true, true} ->
false;
_ ->
Mentions = maps:get(<<"mentions">>, MessageData, []),
MentionRoles = maps:get(<<"mention_roles">>, MessageData, []),
UserRoles = maps:get(UserId, UserRolesMap, []),
is_user_in_mentions(UserId, Mentions) orelse
case SuppressRoles of
true -> false;
false -> has_mentioned_role(UserRoles, MentionRoles)
end
end.
is_user_in_mentions(UserId, Mentions) ->
lists:any(fun(Mention) -> mention_matches_user(UserId, Mention) end, Mentions).
mention_matches_user(UserId, Mention) ->
case maps:get(<<"id">>, Mention, undefined) of
undefined ->
false;
Id when is_integer(Id) ->
Id =:= UserId;
Id when is_binary(Id) ->
case validation:validate_snowflake(<<"mention.id">>, Id) of
{ok, ParsedId} -> ParsedId =:= UserId;
_ -> false
end;
_ -> false
end.
has_mentioned_role([], _) ->
false;
has_mentioned_role([RoleId | Rest], MentionRoles) ->
case role_in_mentions(RoleId, MentionRoles) of
true -> true;
false -> has_mentioned_role(Rest, MentionRoles)
end.
role_in_mentions(RoleId, MentionRoles) ->
RoleBin = integer_to_binary(RoleId),
lists:any(
fun(MentionRole) ->
case MentionRole of
Value when is_integer(Value) ->
Value =:= RoleId;
Value when is_binary(Value) ->
Value =:= RoleBin;
_ ->
false
end
end,
MentionRoles
).
resolve_message_notifications(ChannelId, Settings, GuildDefaultNotifications) ->
ChannelOverrides = maps:get(channel_overrides, Settings, #{}),
ChannelKey = integer_to_binary(ChannelId),
Level =
case maps:get(ChannelKey, ChannelOverrides, undefined) of
undefined ->
undefined;
Override ->
maps:get(message_notifications, Override, ?MESSAGE_NOTIFICATIONS_NULL)
end,
case Level of
?MESSAGE_NOTIFICATIONS_NULL -> resolve_guild_notification(Settings, GuildDefaultNotifications);
?MESSAGE_NOTIFICATIONS_INHERIT -> resolve_guild_notification(Settings, GuildDefaultNotifications);
undefined -> resolve_guild_notification(Settings, GuildDefaultNotifications);
Valid -> normalize_notification_level(Valid)
end.
resolve_guild_notification(Settings, GuildDefaultNotifications) ->
Level = maps:get(message_notifications, Settings, ?MESSAGE_NOTIFICATIONS_NULL),
case Level of
?MESSAGE_NOTIFICATIONS_NULL -> normalize_notification_level(GuildDefaultNotifications);
?MESSAGE_NOTIFICATIONS_INHERIT -> normalize_notification_level(GuildDefaultNotifications);
Valid -> normalize_notification_level(Valid)
end.
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_ALL ->
Level;
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_ONLY_MENTIONS ->
Level;
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_NO_MESSAGES ->
Level;
normalize_notification_level(_) ->
?MESSAGE_NOTIFICATIONS_ALL.
override_for_large_guild(GuildId, CurrentLevel, _State) ->
case get_guild_large_metadata(GuildId) of
undefined ->
CurrentLevel;
#{member_count := Count, features := Features} ->
case is_large_guild(Count, Features) of
true -> enforce_only_mentions(CurrentLevel);
false -> CurrentLevel
end
end.
enforce_only_mentions(CurrentLevel) ->
case CurrentLevel of
0 -> 1;
_ -> CurrentLevel
end.
is_large_guild(Count, Features) when is_integer(Count) ->
Count > ?LARGE_GUILD_THRESHOLD orelse has_large_guild_override(Features);
is_large_guild(_, Features) ->
has_large_guild_override(Features).
has_large_guild_override(Features) when is_list(Features) ->
lists:member(?LARGE_GUILD_OVERRIDE_FEATURE, Features);
has_large_guild_override(_) ->
false.
get_guild_large_metadata(GuildId) ->
GuildName = process_registry:build_process_name(guild, GuildId),
try
case whereis(GuildName) of
undefined ->
undefined;
Pid when is_pid(Pid) ->
case gen_server:call(Pid, {get_large_guild_metadata}, 500) of
#{member_count := Count, features := Features} ->
#{member_count => Count, features => Features};
_ ->
undefined
end
end
catch
_:_ -> undefined
end.

View File

@@ -0,0 +1,35 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_logger_filter).
-export([install_progress_filter/0]).
install_progress_filter() ->
Filter = {fun logger_filters:progress/2, stop},
case logger:add_handler_filter(default, push_progress_filter, Filter) of
ok ->
ok;
{error, already_exists} ->
ok;
{error, Reason} ->
logger:error(
"[push] failed to install progress filter: ~p",
[Reason]
),
{error, Reason}
end.

View File

@@ -0,0 +1,131 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_notification).
-export([sanitize_mentions/2, build_notification_title/5, build_notification_payload/10]).
sanitize_mentions(Content, Mentions) ->
lists:foldl(
fun(Mention, Acc) ->
case
{
maps:get(<<"id">>, Mention, undefined),
maps:get(<<"username">>, Mention, undefined)
}
of
{undefined, _} ->
Acc;
{_, undefined} ->
Acc;
{Id, Username} ->
Pattern = <<"<@", Id/binary, ">">>,
Replacement = <<"@", Username/binary>>,
binary:replace(Acc, Pattern, Replacement, [global])
end
end,
Content,
Mentions
).
build_notification_title(AuthorUsername, MessageData, GuildId, GuildName, ChannelName) ->
ChannelType = maps:get(<<"channel_type">>, MessageData, 1),
case GuildId of
0 ->
case ChannelType of
3 ->
iolist_to_binary([AuthorUsername, <<" (Group DM)">>]);
_ ->
AuthorUsername
end;
_ ->
case {ChannelName, GuildName} of
{undefined, _} ->
AuthorUsername;
{_, undefined} ->
AuthorUsername;
{ChanName, GName} ->
iolist_to_binary([
AuthorUsername,
<<" (#">>,
ChanName,
<<", ">>,
GName,
<<")">>
])
end
end.
build_notification_payload(
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
AuthorUsername,
AuthorAvatarUrl,
TargetUserId,
BadgeCount
) ->
Content = maps:get(<<"content">>, MessageData, <<"">>),
Mentions = maps:get(<<"mentions">>, MessageData, []),
SanitizedContent = sanitize_mentions(Content, Mentions),
ContentPreview =
case byte_size(SanitizedContent) > 100 of
true -> binary:part(SanitizedContent, 0, 100);
false -> SanitizedContent
end,
Title = build_notification_title(AuthorUsername, MessageData, GuildId, GuildName, ChannelName),
BadgeValue = max(0, BadgeCount),
#{
<<"title">> => Title,
<<"body">> => ContentPreview,
<<"icon">> => AuthorAvatarUrl,
<<"badge">> => <<"https://fluxerstatic.com/web/apple-touch-icon.png">>,
<<"data">> =>
#{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId),
<<"guild_id">> =>
case GuildId of
0 -> null;
_ -> integer_to_binary(GuildId)
end,
<<"url">> =>
case GuildId of
0 ->
iolist_to_binary([
<<"/channels/@me/">>,
integer_to_binary(ChannelId),
<<"/">>,
integer_to_binary(MessageId)
]);
_ ->
iolist_to_binary([
<<"/channels/">>,
integer_to_binary(GuildId),
<<"/">>,
integer_to_binary(ChannelId),
<<"/">>,
integer_to_binary(MessageId)
])
end,
<<"badge_count">> => BadgeValue,
<<"target_user_id">> => integer_to_binary(TargetUserId)
}
}.

View File

@@ -0,0 +1,320 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_sender).
-import(push_cache, [
get_user_badge_count/2,
cache_user_badge_count/4
]).
-import(rpc_client, [call/1]).
-export([send_to_user_subscriptions/9, send_push_notifications/8]).
send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
) ->
AuthorData = maps:get(<<"author">>, MessageData, #{}),
AuthorUsername = maps:get(<<"username">>, AuthorData, <<"Unknown">>),
AuthorAvatar = maps:get(<<"avatar">>, AuthorData, null),
AuthorAvatarUrl =
case AuthorAvatar of
null -> push_utils:get_default_avatar_url(maps:get(<<"id">>, AuthorData, <<"0">>));
Hash -> push_utils:construct_avatar_url(maps:get(<<"id">>, AuthorData, <<"0">>), Hash)
end,
NotificationPayload = push_notification:build_notification_payload(
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
AuthorUsername,
AuthorAvatarUrl,
UserId,
BadgeCount
),
case ensure_vapid_credentials() of
{ok, VapidEmail, VapidPublicKey, VapidPrivateKey} ->
FailedSubscriptions = lists:filtermap(
fun(Sub) ->
send_notification_to_subscription(
UserId,
Sub,
NotificationPayload,
VapidEmail,
VapidPublicKey,
VapidPrivateKey
)
end,
Subscriptions
),
case FailedSubscriptions of
[] ->
ok;
_ ->
logger:debug("[push] Deleting ~p failed subscriptions", [
length(FailedSubscriptions)
]),
push_subscriptions:delete_failed_subscriptions(FailedSubscriptions)
end;
{error, Reason} ->
logger:warning("[push] %s - skipping push send", [Reason])
end.
send_push_notifications(
UserIds, MessageData, GuildId, ChannelId, MessageId, GuildName, ChannelName, State
) ->
{BadgeCounts, StateWithBadgeCounts} = ensure_badge_counts(UserIds, State),
{UncachedUsers, CachedState} = lists:foldl(
fun(UserId, {Uncached, S}) ->
Key = {subscriptions, UserId},
PushSubscriptionsCache = maps:get(push_subscriptions_cache, S, #{}),
case maps:is_key(Key, PushSubscriptionsCache) of
true ->
Subscriptions = push_cache:get_user_push_subscriptions(UserId, S),
logger:debug(
"[push] Using cached subscriptions for user ~p (~p subs)",
[UserId, length(Subscriptions)]
),
BadgeCount = maps:get(UserId, BadgeCounts, 0),
case Subscriptions of
[] ->
ok;
_ ->
send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
)
end,
{Uncached, S};
false ->
{[UserId | Uncached], S}
end
end,
{[], StateWithBadgeCounts},
UserIds
),
case UncachedUsers of
[] ->
CachedState;
_ ->
push_subscriptions:fetch_and_send_subscriptions(
UncachedUsers,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
CachedState,
BadgeCounts
)
end.
ensure_badge_counts(UserIds, State) ->
Now = erlang:system_time(second),
TTL = maps:get(badge_counts_ttl_seconds, State, 0),
{CachedCounts, Missing} =
lists:foldl(
fun(UserId, {Acc, MissingAcc}) ->
case get_user_badge_count(UserId, State) of
{Count, Timestamp} when TTL > 0, Now - Timestamp < TTL ->
{maps:put(UserId, Count, Acc), MissingAcc};
_ ->
{Acc, [UserId | MissingAcc]}
end
end,
{#{}, []},
UserIds
),
UniqueMissing = lists:usort(Missing),
case UniqueMissing of
[] ->
{CachedCounts, State};
_ ->
fetch_badge_counts(UniqueMissing, CachedCounts, State, Now)
end.
fetch_badge_counts(UserIds, Counts, State, CachedAt) ->
Request = #{
<<"type">> => <<"get_badge_counts">>,
<<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds]
},
case call(Request) of
{ok, Data} ->
BadgeData = maps:get(<<"badge_counts">>, Data, #{}),
lists:foldl(
fun(UserId, {Acc, S}) ->
UserIdBin = integer_to_binary(UserId),
Count = normalize_badge_count(maps:get(UserIdBin, BadgeData, 0)),
NewState = cache_user_badge_count(UserId, Count, CachedAt, S),
{maps:put(UserId, Count, Acc), NewState}
end,
{Counts, State},
UserIds
);
{error, Reason} ->
logger:error("[push] Failed to fetch badge counts: ~p", [Reason]),
{Counts, State}
end.
normalize_badge_count(Value) when is_integer(Value), Value >= 0 ->
Value;
normalize_badge_count(_) ->
0.
-define(PUSH_TTL, <<"86400">>).
ensure_vapid_credentials() ->
Email = fluxer_gateway_env:get(vapid_email),
Public = fluxer_gateway_env:get(vapid_public_key),
Private = fluxer_gateway_env:get(vapid_private_key),
case {Email, Public, Private} of
{Email0, Public0, Private0}
when is_binary(Email0) andalso is_binary(Public0) andalso is_binary(Private0) andalso
byte_size(Public0) > 0 andalso byte_size(Private0) > 0 ->
{ok, Email0, Public0, Private0};
_ ->
{error, "Missing VAPID credentials"}
end.
send_notification_to_subscription(
UserId,
Subscription,
Payload,
VapidEmail,
VapidPublicKey,
VapidPrivateKey
) ->
case extract_subscription_fields(Subscription) of
{ok, Endpoint, P256dhKey, AuthKey, SubscriptionId} ->
logger:debug("[push] Sending to endpoint ~p for user ~p", [Endpoint, UserId]),
VapidClaims = #{
<<"sub">> => <<"mailto:", VapidEmail/binary>>,
<<"aud">> => push_utils:extract_origin(Endpoint),
<<"exp">> => erlang:system_time(second) + 43200
},
VapidTokenResult =
try
{ok, push_utils:generate_vapid_token(VapidClaims, VapidPublicKey, VapidPrivateKey)}
catch
C:R ->
logger:error("[push] VAPID token generation failed: ~p:~p", [C, R]),
{error, {C, R}}
end,
case VapidTokenResult of
{ok, VapidToken} ->
case push_utils:encrypt_payload(jsx:encode(Payload), P256dhKey, AuthKey, 4096) of
{ok, EncryptedBody} ->
Headers = build_push_headers(VapidToken, VapidPublicKey),
handle_push_response(UserId, SubscriptionId, Endpoint, Headers, EncryptedBody);
{error, EncryptError} ->
logger:error("[push] Failed to encrypt payload: ~p", [EncryptError]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"encryption_error">>}),
false
end;
{error, _} ->
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"vapid_error">>}),
false
end;
{error, Reason} ->
logger:error("[push] Invalid subscription for user ~p: ~s", [UserId, Reason]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"invalid_subscription">>}),
false
end.
extract_subscription_fields(Subscription) ->
case
{
maps:get(<<"endpoint">>, Subscription, undefined),
maps:get(<<"p256dh_key">>, Subscription, undefined),
maps:get(<<"auth_key">>, Subscription, undefined),
maps:get(<<"subscription_id">>, Subscription, undefined)
}
of
{Endpoint, P256dhKey, AuthKey, SubscriptionId}
when is_binary(Endpoint) andalso is_binary(P256dhKey)
andalso is_binary(AuthKey) andalso is_binary(SubscriptionId) ->
{ok, Endpoint, P256dhKey, AuthKey, SubscriptionId};
_ ->
{error, "missing keys"}
end.
build_push_headers(VapidToken, VapidPublicKey) ->
[
{<<"TTL">>, ?PUSH_TTL},
{<<"Content-Type">>, <<"application/octet-stream">>},
{<<"Content-Encoding">>, <<"aes128gcm">>},
{<<"Authorization">>,
<<"vapid t=", VapidToken/binary, ", k=", VapidPublicKey/binary>>}
].
handle_push_response(UserId, SubscriptionId, Endpoint, Headers, Body) ->
case hackney:request(post, binary_to_list(Endpoint), Headers, Body, []) of
{ok, Status, _, _} when Status >= 200, Status < 300 ->
logger:debug("[push] Push sent successfully (%p) for user %p", [Status, UserId]),
metrics_client:counter(<<"push.success">>),
false;
{ok, 410, _, _} ->
logger:debug("[push] Subscription expired (410) for user ~p", [UserId]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"expired">>}),
{true, delete_payload(UserId, SubscriptionId)};
{ok, 404, _, _} ->
logger:debug("[push] Subscription not found (404) for user ~p", [UserId]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"not_found">>}),
{true, delete_payload(UserId, SubscriptionId)};
{ok, Status, _, ClientRef} ->
{ok, ErrorBody} = hackney:body(ClientRef),
logger:error("[push] Push failed with status ~p for user ~p (%s)", [
Status,
UserId,
ErrorBody
]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"http_error">>}),
false;
{error, Reason} ->
logger:error("[push] Failed to send push for user ~p: ~p", [UserId, Reason]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"network_error">>}),
false
end.
delete_payload(UserId, SubscriptionId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"subscription_id">> => SubscriptionId
}.

View File

@@ -0,0 +1,102 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_subscriptions).
-export([fetch_and_send_subscriptions/9]).
-export([fetch_and_cache_user_guild_settings/3]).
-export([delete_failed_subscriptions/1]).
fetch_and_send_subscriptions(
UserIds,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
State,
BadgeCounts
) ->
SubscriptionsReq = #{
<<"type">> => <<"get_push_subscriptions">>,
<<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds]
},
case rpc_client:call(SubscriptionsReq) of
{ok, SubscriptionsData} ->
NewState = lists:foldl(
fun(UserId, S) ->
UserIdBin = integer_to_binary(UserId),
case maps:get(UserIdBin, SubscriptionsData, []) of
[] ->
push_cache:cache_user_subscriptions(UserId, [], S);
Subscriptions ->
BadgeCount = maps:get(UserId, BadgeCounts, 0),
push_sender:send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
),
push_cache:cache_user_subscriptions(UserId, Subscriptions, S)
end
end,
State,
UserIds
),
NewState;
{error, _Reason} ->
State
end.
fetch_and_cache_user_guild_settings(UserId, GuildId, _State) ->
Req = #{
<<"type">> => <<"get_user_guild_settings">>,
<<"user_ids">> => [integer_to_binary(UserId)],
<<"guild_id">> => integer_to_binary(GuildId)
},
case rpc_client:call(Req) of
{ok, Data} ->
UserGuildSettings = maps:get(<<"user_guild_settings">>, Data, [null]),
[SettingsData | _] = UserGuildSettings,
case SettingsData of
null ->
null;
Settings ->
gen_server:cast(
push, {cache_user_guild_settings, UserId, GuildId, Settings}
),
Settings
end;
{error, Reason} ->
null
end.
delete_failed_subscriptions(FailedSubscriptions) ->
DeleteReq = #{
<<"type">> => <<"delete_push_subscriptions">>,
<<"subscriptions">> => FailedSubscriptions
},
rpc_client:call(DeleteReq).

View File

@@ -0,0 +1,262 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(push_utils).
-export([
construct_avatar_url/2,
get_default_avatar_url/1,
extract_origin/1,
generate_vapid_token/3,
base64url_encode/1,
base64url_decode/1,
encrypt_payload/4,
decode_subscription_key/1,
hkdf_expand/4,
hkdf_expand_loop/6,
parse_timestamp/1
]).
construct_avatar_url(UserId, Hash) ->
MediaProxyBin = media_proxy_endpoint_binary(),
iolist_to_binary([
MediaProxyBin,
<<"/avatars/">>,
UserId,
<<"/">>,
Hash,
<<".png">>
]).
get_default_avatar_url(UserId) ->
Index = avatar_index(UserId),
iolist_to_binary([
<<"https://fluxerstatic.com/avatars/">>,
integer_to_binary(Index),
<<".png">>
]).
avatar_index(UserId) ->
case catch binary_to_integer(UserId) of
{'EXIT', _} -> 0;
Value -> wrap_avatar_index(Value)
end.
wrap_avatar_index(Value) ->
Rem = Value rem 6,
case Rem < 0 of
true -> Rem + 6;
false -> Rem
end.
media_proxy_endpoint_binary() ->
case fluxer_gateway_env:get(media_proxy_endpoint) of
undefined ->
erlang:error({missing_config, media_proxy_endpoint});
Endpoint ->
value_to_binary(Endpoint)
end.
value_to_binary(Value) when is_binary(Value) ->
Value;
value_to_binary(Value) when is_list(Value) ->
list_to_binary(Value).
extract_origin(Url) ->
case binary:split(Url, <<"://">>) of
[Protocol, Rest] ->
case binary:split(Rest, <<"/">>) of
[Host | _] -> <<Protocol/binary, "://", Host/binary>>;
_ -> Url
end;
_ ->
Url
end.
generate_vapid_token(Claims, PublicKeyB64Url, PrivateKeyB64Url) ->
logger:debug("[push] Generating VAPID token", []),
try
application:ensure_all_started(crypto),
application:ensure_all_started(public_key),
application:ensure_all_started(jose),
PrivRaw =
case base64url_decode(PrivateKeyB64Url) of
error ->
logger:error("[push] Failed to decode private key: ~p", [PrivateKeyB64Url]),
erlang:error(invalid_private_key);
PrivDecoded ->
PrivDecoded
end,
PubRaw =
case base64url_decode(PublicKeyB64Url) of
error ->
logger:error("[push] Failed to decode public key: ~p", [PublicKeyB64Url]),
erlang:error(invalid_public_key);
PubDecoded ->
PubDecoded
end,
<<4, X:32/binary, Y:32/binary>> = PubRaw,
B64 = fun(Bin) -> base64url_encode(Bin) end,
JWKMap = #{
<<"kty">> => <<"EC">>,
<<"crv">> => <<"P-256">>,
<<"d">> => B64(PrivRaw),
<<"x">> => B64(X),
<<"y">> => B64(Y)
},
JWK0 = jose_jwk:from_map(JWKMap),
JWK =
case JWK0 of
{JW, _Fields} -> JW;
JW -> JW
end,
Header = #{<<"alg">> => <<"ES256">>, <<"typ">> => <<"JWT">>},
JWS = jose_jwt:sign(JWK, Header, Claims),
Compact0 = jose_jws:compact(JWS),
CompactBin =
case Compact0 of
{_Meta, Bin} when is_binary(Bin) -> Bin;
Other ->
logger:error("[push] Unexpected compact return: ~p", [Other]),
erlang:error({unexpected_compact_return, Other})
end,
logger:debug("[push] Generated VAPID token successfully"),
CompactBin
catch
C:R:Stack ->
logger:error("[push] VAPID token generation failed: ~p:~p~n~p", [C, R, Stack]),
erlang:error({vapid_token_generation_failed, C, R})
end.
base64url_encode(Data) ->
jose_base64url:encode(Data).
base64url_decode(Data) ->
case jose_base64url:decode(Data) of
{ok, Decoded} -> Decoded;
error -> error
end.
encrypt_payload(Message, PeerPubB64, AuthSecretB64, RecordSize0) ->
try
logger:debug(
"[push] Encrypting payload with p256dh key size: ~p, auth key size: ~p",
[byte_size(PeerPubB64), byte_size(AuthSecretB64)]
),
PeerPub = decode_subscription_key(PeerPubB64),
AuthSecret = decode_subscription_key(AuthSecretB64),
RecordSize =
case RecordSize0 of
0 -> 4096;
_ -> RecordSize0
end,
RecordLen = RecordSize - 16,
Salt = crypto:strong_rand_bytes(16),
{LocalPub, LocalPriv} = crypto:generate_key(ecdh, prime256v1),
<<4, _/binary>> = PeerPub,
Secret = crypto:compute_key(ecdh, PeerPub, LocalPriv, prime256v1),
PRKInfo = <<"WebPush: info", 0, PeerPub/binary, LocalPub/binary>>,
IKM = hkdf_expand(Secret, AuthSecret, PRKInfo, 32),
CEKInfo = <<"Content-Encoding: aes128gcm", 0>>,
NonceInfo = <<"Content-Encoding: nonce", 0>>,
CEK = hkdf_expand(IKM, Salt, CEKInfo, 16),
Nonce = hkdf_expand(IKM, Salt, NonceInfo, 12),
HeaderLen = 16 + 4 + 1 + byte_size(LocalPub),
Data0 = <<Message/binary, 16#02>>,
Required = RecordLen - HeaderLen,
Data0Len = byte_size(Data0),
case Data0Len =< Required of
false ->
{error, max_pad_exceeded};
true ->
PadLen = Required - Data0Len,
Padding =
case PadLen of
0 -> <<>>;
_ -> binary:copy(<<0>>, PadLen)
end,
Data = <<Data0/binary, Padding/binary>>,
{Cipher, Tag} = crypto:crypto_one_time_aead(
aes_gcm, CEK, Nonce, Data, <<>>, 16, true
),
Ciphertext = <<Cipher/binary, Tag/binary>>,
Body = <<
Salt/binary,
RecordSize:32/big-unsigned-integer,
(byte_size(LocalPub)):8,
LocalPub/binary,
Ciphertext/binary
>>,
{ok, Body}
end
catch
C:R:Stack ->
logger:error("[push] Encryption failed: ~p:~p~nStack: ~p", [C, R, Stack]),
{error, encryption_failed}
end.
decode_subscription_key(B64) when is_binary(B64) ->
Padded =
case byte_size(B64) rem 4 of
0 -> B64;
Rem -> <<B64/binary, (binary:copy(<<"=">>, 4 - Rem))/binary>>
end,
case jose_base64url:decode(Padded) of
{ok, Decoded} ->
Decoded;
_ ->
try base64:decode(Padded) of
Decoded when is_binary(Decoded) -> Decoded
catch
_:_ -> erlang:error(decode_key_error)
end
end.
hkdf_expand(IKM, Salt, Info, Length) ->
PRK = crypto:mac(hmac, sha256, Salt, IKM),
hkdf_expand_loop(PRK, Info, Length, 1, <<>>, <<>>).
hkdf_expand_loop(_PRK, _Info, Length, _I, _Tprev, Acc) when byte_size(Acc) >= Length ->
binary:part(Acc, 0, Length);
hkdf_expand_loop(PRK, Info, Length, I, Tprev, Acc) ->
T = crypto:mac(hmac, sha256, PRK, <<Tprev/binary, Info/binary, I:8/integer>>),
hkdf_expand_loop(PRK, Info, Length, I + 1, T, <<Acc/binary, T/binary>>).
parse_timestamp(Str) when is_binary(Str) ->
try
binary_to_integer(Str)
catch
_:_ -> undefined
end;
parse_timestamp(_) ->
undefined.