initial commit
This commit is contained in:
268
fluxer_gateway/src/push/push.erl
Normal file
268
fluxer_gateway/src/push/push.erl
Normal 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.
|
||||
161
fluxer_gateway/src/push/push_cache.erl
Normal file
161
fluxer_gateway/src/push/push_cache.erl
Normal 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.
|
||||
37
fluxer_gateway/src/push/push_core.erl
Normal file
37
fluxer_gateway/src/push/push_core.erl
Normal 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}).
|
||||
312
fluxer_gateway/src/push/push_eligibility.erl
Normal file
312
fluxer_gateway/src/push/push_eligibility.erl
Normal 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.
|
||||
35
fluxer_gateway/src/push/push_logger_filter.erl
Normal file
35
fluxer_gateway/src/push/push_logger_filter.erl
Normal 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.
|
||||
131
fluxer_gateway/src/push/push_notification.erl
Normal file
131
fluxer_gateway/src/push/push_notification.erl
Normal 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)
|
||||
}
|
||||
}.
|
||||
320
fluxer_gateway/src/push/push_sender.erl
Normal file
320
fluxer_gateway/src/push/push_sender.erl
Normal 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
|
||||
}.
|
||||
102
fluxer_gateway/src/push/push_subscriptions.erl
Normal file
102
fluxer_gateway/src/push/push_subscriptions.erl
Normal 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).
|
||||
262
fluxer_gateway/src/push/push_utils.erl
Normal file
262
fluxer_gateway/src/push/push_utils.erl
Normal 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.
|
||||
Reference in New Issue
Block a user