Перенести все админские эндпоинты на порт 8445 и добавить отдельную авторизацию для админов. Часть 1

This commit is contained in:
2026-04-27 15:54:48 +03:00
parent 62bc62f990
commit 4ed6a961ab
40 changed files with 3573 additions and 800 deletions

View File

@@ -1,85 +0,0 @@
-module(core_banned_word).
-include("records.hrl").
-export([add/1, remove/1, list_all/0, is_banned/1]).
-export([check_text/1, filter_text/1]).
%% Добавить слово в бан-лист
add(Word) when is_binary(Word) ->
WordLower = string:lowercase(Word),
case is_banned(WordLower) of
true -> {error, already_exists};
false ->
BannedWord = #banned_word{
id = generate_id(),
word = WordLower
},
F = fun() ->
mnesia:write(BannedWord),
{ok, BannedWord}
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
%% Удалить слово из бан-листа
remove(Word) when is_binary(Word) ->
WordLower = string:lowercase(Word),
Match = #banned_word{word = WordLower, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> {error, not_found};
[BannedWord] ->
F = fun() ->
mnesia:delete_object(BannedWord),
{ok, removed}
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
%% Список всех запрещённых слов
list_all() ->
Match = #banned_word{_ = '_'},
Words = mnesia:dirty_match_object(Match),
{ok, [W#banned_word.word || W <- Words]}.
%% Проверить, является ли слово запрещённым
is_banned(Word) when is_binary(Word) ->
WordLower = string:lowercase(Word),
Match = #banned_word{word = WordLower, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> false;
_ -> true
end.
%% Проверить текст на наличие запрещённых слов
check_text(Text) when is_binary(Text) ->
TextLower = string:lowercase(Text),
Words = string:split(TextLower, " ", all),
{ok, BannedWords} = list_all(),
lists:any(fun(W) -> lists:member(W, BannedWords) end, Words).
%% Отфильтровать запрещённые слова (заменить на ***)
filter_text(Text) when is_binary(Text) ->
{ok, BannedWords} = list_all(),
Words = binary:split(Text, <<" ">>, [global]),
Filtered = lists:map(fun(W) ->
case lists:member(string:lowercase(W), BannedWords) of
true -> <<"***">>;
false -> W
end
end, Words),
iolist_to_binary(join_binary(Filtered, <<" ">>)).
join_binary([], _) -> [];
join_binary([H], _) -> [H];
join_binary([H|T], Sep) ->
[H, Sep | join_binary(T, Sep)].
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -0,0 +1,54 @@
-module(core_banned_words).
-export([list_banned_words/0,
add_banned_word/2,
remove_banned_word/1,
update_banned_word/2]).
-include("records.hrl").
list_banned_words() ->
mnesia:dirty_match_object(#banned_word{_ = '_'}).
add_banned_word(Word, AddedBy) ->
Id = generate_id(),
Now = calendar:universal_time(),
BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now},
case mnesia:transaction(fun() ->
case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of
[] -> mnesia:write(BW), {ok, BW};
_ -> mnesia:abort(already_exists)
end
end) of
{atomic, {ok, BWRec}} -> {ok, BWRec};
{aborted, already_exists} -> {error, already_exists};
{aborted, Reason} -> {error, Reason}
end.
remove_banned_word(Word) ->
case mnesia:transaction(fun() ->
case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of
[Rec] -> mnesia:delete_object(Rec), {ok, deleted};
[] -> mnesia:abort(not_found)
end
end) of
{atomic, {ok, deleted}} -> {ok, deleted};
{aborted, not_found} -> {error, not_found}
end.
update_banned_word(OldWord, NewWord) ->
case mnesia:transaction(fun() ->
case mnesia:match_object(#banned_word{word = OldWord, _ = '_'}) of
[Rec] ->
Updated = Rec#banned_word{word = NewWord},
mnesia:write(Updated),
{ok, Updated};
[] ->
mnesia:abort(not_found)
end
end) of
{atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec};
{aborted, not_found} -> {error, not_found}
end.
generate_id() ->
base64:encode(crypto:strong_rand_bytes(9)).

View File

@@ -4,6 +4,7 @@
-export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]).
-export([email_exists/1]).
-export([generate_id/0]).
-export([list_users/0]).
%% Создание пользователя
create(Email, Password) ->
@@ -86,6 +87,22 @@ update(Id, Updates) ->
delete(Id) ->
update(Id, [{status, deleted}]).
list_users() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
{ok, [user_to_map(U) || U <- ActiveUsers]}.
user_to_map(User) ->
#{
id => User#user.id,
email => User#user.email,
password_hash => User#user.password_hash,
role => User#user.role,
status => User#user.status,
created_at => User#user.created_at,
updated_at => User#user.updated_at
}.
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -1,21 +1,18 @@
-module(eventhub_app).
-behaviour(application).
-export([start/2, stop/1]).
start(_StartType, _StartArgs) ->
pg:start_link(),
application:ensure_all_started(mnesia),
application:ensure_all_started(cowboy),
case infra_sup:start_link() of
{ok, Pid} ->
ok = infra_mnesia:init_tables(),
ok = infra_mnesia:wait_for_tables(),
connect_nodes(),
start_http(),
start_admin_http(),
% Запускаем сборщик метрик Prometheus
start_http(), % Пользовательский API (8080)
start_admin_http(), % Административный API (8445)
application:ensure_all_started(prometheus),
application:ensure_all_started(prometheus_cowboy),
{ok, Pid};
@@ -23,12 +20,13 @@ start(_StartType, _StartArgs) ->
Error
end.
stop(_State) ->
ok.
stop(_State) -> ok.
%% ===================================================================
%% Пользовательский HTTP (порт 8080) — только публичные эндпоинты
%% ===================================================================
start_http() ->
Port = application:get_env(eventhub, http_port, 8080),
Dispatch = cowboy_router:compile([
{'_', [
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
@@ -52,83 +50,66 @@ start_http() ->
{"/v1/reviews/:id", handler_review_by_id, []},
{"/v1/reports", handler_reports, []},
{"/v1/tickets", handler_tickets, []},
{"/v1/subscription", handler_subscription, []},
% Админские маршруты - более конкретные ПЕРЕД общими
{"/v1/admin/reports", handler_reports, []},
{"/v1/admin/reports/:id", handler_report_by_id, []},
{"/v1/admin/reviews/:id", handler_admin_reviews, []},
{"/v1/admin/banned-words", handler_banned_words, []},
{"/v1/admin/banned-words/:word", handler_banned_words, []},
{"/v1/admin/tickets/stats", handler_ticket_stats, []},
{"/v1/admin/tickets/:id", handler_ticket_by_id, []},
{"/v1/admin/tickets", handler_tickets, []},
{"/v1/admin/subscriptions", handler_admin_subscriptions, []},
{"/v1/admin/subscriptions/:id", handler_admin_subscriptions, []},
% Общий маршрут для заморозки (должен быть последним)
{"/v1/admin/:target_type/:id", handler_admin_moderation, []}
{"/v1/subscription", handler_subscription, []}
]}
]),
Middlewares = [
cowboy_router,
cowboy_handler
],
Middlewares = [cowboy_router, cowboy_handler],
Env = #{dispatch => Dispatch},
cowboy:start_clear(http, [{port, Port}], #{
env => Env,
middlewares => Middlewares
}),
cowboy:start_clear(http, [{port, Port}], #{env => Env, middlewares => Middlewares}),
io:format("HTTP server started on port ~p~n", [Port]).
%% ===================================================================
%% Административный HTTP (порт 8445) — все админские эндпоинты
%% ===================================================================
start_admin_http() ->
Port = application:get_env(eventhub, admin_http_port, 8445),
Dispatch = cowboy_router:compile([
{'_', [
{"/admin/health", admin_handler_health, []},
{"/admin/stats", admin_handler_stats, []},
{"/admin/users", admin_handler_users, []},
{"/admin/users/:id", admin_handler_user_by_id, []}
% ================== БАЗОВЫЕ ==================
{"/v1/admin/health", admin_handler_health, []},
{"/v1/admin/stats", admin_handler_stats, []},
{"/v1/admin/login", admin_handler_login, []},
% ================== ПОЛЬЗОВАТЕЛИ ==================
{"/v1/admin/users", admin_handler_users, []},
{"/v1/admin/users/:id", admin_handler_user_by_id, []},
% ================== ОТЧЁТЫ ==================
{"/v1/admin/reports", admin_handler_reports, []},
{"/v1/admin/reports/:id", admin_handler_report_by_id, []},
% ================== ОТЗЫВЫ ==================
{"/v1/admin/reviews/:id", admin_handler_reviews, []},
% ================== БАН-СЛОВА ==================
{"/v1/admin/banned-words", admin_handler_banned_words, []},
{"/v1/admin/banned-words/:word", admin_handler_banned_words, []},
% ================== ТИКЕТЫ ==================
{"/v1/admin/tickets/stats", admin_handler_ticket_stats, []},
{"/v1/admin/tickets/:id", admin_handler_ticket_by_id, []},
{"/v1/admin/tickets", admin_handler_tickets, []},
% ================== ПОДПИСКИ ==================
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions, []},
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
{"/v1/admin/:target_type/:id", admin_handler_moderation, []}
]}
]),
Middlewares = [
cowboy_router,
cowboy_handler
],
Middlewares = [cowboy_router, cowboy_handler],
Env = #{dispatch => Dispatch},
cowboy:start_clear(admin_http, [{port, Port}], #{
env => Env,
middlewares => Middlewares
}),
cowboy:start_clear(admin_http, [{port, Port}], #{env => Env, middlewares => Middlewares}),
io:format("Admin HTTP server started on port ~p~n", [Port]),
% WebSocket для пользователей
WsDispatch = cowboy_router:compile([
{'_', [{"/ws", ws_handler, []}]}
]),
cowboy:start_clear(ws, [{port, 8081}], #{
env => #{dispatch => WsDispatch}
}),
WsDispatch = cowboy_router:compile([{'_', [{"/ws", ws_handler, []}]}]),
cowboy:start_clear(ws, [{port, 8081}], #{env => #{dispatch => WsDispatch}}),
% WebSocket для админов
AdminWsDispatch = cowboy_router:compile([
{'_', [{"/admin/ws", admin_ws_handler, []}]}
]),
cowboy:start_clear(admin_ws, [{port, 8446}], #{
env => #{dispatch => AdminWsDispatch}
}),
AdminWsDispatch = cowboy_router:compile([{'_', [{"/admin/ws", admin_ws_handler, []}]}]),
cowboy:start_clear(admin_ws, [{port, 8446}], #{env => #{dispatch => AdminWsDispatch}}),
io:format("WebSocket started on ports 8081 (user) and 8446 (admin)~n").
%% ===================================================================
%% Ручное подключение к нодам кластера (запасной вариант)
%% ===================================================================
connect_nodes() ->
case os:getenv("JOIN_NODES") of
false -> ok;

View File

@@ -0,0 +1,136 @@
-module(admin_handler_banned_words).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:binding(word, Req) of
undefined -> handle_collection(Req);
Word -> handle_item(Word, Req)
end.
handle_collection(Req) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_banned_words(Req);
<<"POST">> -> add_banned_word(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
handle_item(Word, Req) ->
case cowboy_req:method(Req) of
<<"DELETE">> -> delete_banned_word(Word, Req);
<<"PUT">> -> update_banned_word(Word, Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
list_banned_words(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
Words = core_banned_words:list_banned_words(),
send_json(Req1, 200, [banned_word_to_json(W) || W <- Words]);
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
add_banned_word(Req) ->
case auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"word">> := NewWord} ->
case core_banned_words:add_banned_word(NewWord, AdminId) of
{ok, WordRec} ->
send_json(Req2, 201, banned_word_to_json(WordRec));
{error, already_exists} ->
send_error(Req2, 409, <<"Word already exists">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing 'word' field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
delete_banned_word(Word, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
case core_banned_words:remove_banned_word(Word) of
{ok, deleted} ->
send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
send_error(Req1, 404, <<"Word not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_banned_word(Word, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"word">> := NewWord} ->
case core_banned_words:update_banned_word(Word, NewWord) of
{ok, WordRec} ->
send_json(Req2, 200, banned_word_to_json(WordRec));
{error, not_found} ->
send_error(Req2, 404, <<"Word not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing 'word' field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
banned_word_to_json(BW) ->
#{
id => BW#banned_word.id,
word => BW#banned_word.word,
added_by => BW#banned_word.added_by,
added_at => datetime_to_iso8601(BW#banned_word.added_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,51 @@
-module(admin_handler_login).
-behaviour(cowboy_handler).
-export([init/2]).
init(Req0, State) ->
case cowboy_req:method(Req0) of
<<"POST">> ->
case cowboy_req:has_body(Req0) of
true ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case auth:authenticate_admin_request(Req1, Email, Password) of
{ok, Token, User} ->
Resp = jsx:encode(#{
<<"token">> => Token,
<<"user">> => #{
<<"id">> => maps:get(id, User),
<<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User)
}
}),
Req2 = cowboy_req:reply(200, #{
<<"content-type">> => <<"application/json">>,
<<"access-control-allow-origin">> => <<"*">>
}, Resp, Req1),
{ok, Req2, State};
{error, insufficient_permissions} ->
error_response(403, insufficient_permissions, Req1, State);
{error, Reason} ->
error_response(401, Reason, Req1, State)
end;
_ ->
error_response(400, <<"invalid_request">>, Req1, State)
catch
_:_ -> error_response(400, <<"invalid_request">>, Req1, State)
end;
false ->
error_response(400, <<"Missing request body">>, Req0, State)
end;
_ ->
error_response(405, <<"Method not allowed">>, Req0, State)
end.
error_response(Code, Reason, Req, State) ->
Body = jsx:encode(#{<<"error">> => Reason}),
Req2 = cowboy_req:reply(Code, #{
<<"content-type">> => <<"application/json">>,
<<"access-control-allow-origin">> => <<"*">>
}, Body, Req),
{ok, Req2, State}.

View File

@@ -0,0 +1,154 @@
-module(admin_handler_moderation).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
-define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]).
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"PUT">> -> moderate(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
moderate(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
TargetType = cowboy_req:binding(target_type, Req1),
TargetId = cowboy_req:binding(id, Req1),
case lists:member(TargetType, ?VALID_TARGETS) of
true ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"action">> := Action} ->
apply_moderation(TargetType, TargetId, Action, Req2);
_ ->
send_error(Req2, 400, <<"Missing 'action' field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
false ->
send_error(Req1, 400, <<"Invalid target_type">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
apply_moderation(<<"calendar">>, Id, Action, Req) ->
handle_calendar(Id, Action, Req);
apply_moderation(<<"event">>, Id, Action, Req) ->
handle_event(Id, Action, Req);
apply_moderation(<<"review">>, Id, Action, Req) ->
handle_review(Id, Action, Req);
apply_moderation(<<"user">>, Id, Action, Req) ->
handle_user(Id, Action, Req).
handle_calendar(Id, <<"freeze">>, Req) ->
case core_calendar:freeze(Id) of
{ok, Calendar} -> send_json(Req, 200, calendar_to_json(Calendar));
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>)
end;
handle_calendar(Id, <<"unfreeze">>, Req) ->
case core_calendar:unfreeze(Id) of
{ok, Calendar} -> send_json(Req, 200, calendar_to_json(Calendar));
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>)
end;
handle_calendar(_Id, _Action, Req) ->
send_error(Req, 400, <<"Invalid action for calendar">>).
handle_event(Id, <<"freeze">>, Req) ->
case core_event:freeze(Id) of
{ok, Event} -> send_json(Req, 200, event_to_json(Event));
{error, not_found} -> send_error(Req, 404, <<"Event not found">>)
end;
handle_event(Id, <<"unfreeze">>, Req) ->
case core_event:unfreeze(Id) of
{ok, Event} -> send_json(Req, 200, event_to_json(Event));
{error, not_found} -> send_error(Req, 404, <<"Event not found">>)
end;
handle_event(_Id, _Action, Req) ->
send_error(Req, 400, <<"Invalid action for event">>).
handle_review(Id, <<"hide">>, Req) ->
case core_review:hide(Id) of
{ok, Review} -> send_json(Req, 200, review_to_json(Review));
{error, not_found} -> send_error(Req, 404, <<"Review not found">>)
end;
handle_review(Id, <<"show">>, Req) ->
case core_review:show(Id) of
{ok, Review} -> send_json(Req, 200, review_to_json(Review));
{error, not_found} -> send_error(Req, 404, <<"Review not found">>)
end;
handle_review(_Id, _Action, Req) ->
send_error(Req, 400, <<"Invalid action for review">>).
handle_user(Id, <<"block">>, Req) ->
case core_user:block(Id) of
{ok, User} -> send_json(Req, 200, user_to_json(User));
{error, not_found} -> send_error(Req, 404, <<"User not found">>)
end;
handle_user(Id, <<"unblock">>, Req) ->
case core_user:unblock(Id) of
{ok, User} -> send_json(Req, 200, user_to_json(User));
{error, not_found} -> send_error(Req, 404, <<"User not found">>)
end;
handle_user(_Id, _Action, Req) ->
send_error(Req, 400, <<"Invalid action for user">>).
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
calendar_to_json(C) ->
#{
id => C#calendar.id,
title => C#calendar.title,
status => atom_to_binary(C#calendar.status, utf8)
}.
event_to_json(E) ->
#{
id => E#event.id,
title => E#event.title,
status => atom_to_binary(E#event.status, utf8)
}.
review_to_json(R) ->
#{
id => R#review.id,
status => atom_to_binary(R#review.status, utf8)
}.
user_to_json(U) ->
#{
id => U#user.id,
email => U#user.email,
status => atom_to_binary(U#user.status, utf8)
}.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,96 @@
-module(admin_handler_report_by_id).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_report(Req);
<<"PUT">> -> update_report(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_report(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
ReportId = cowboy_req:binding(id, Req1),
case core_report:get_by_id(ReportId) of
{ok, Report} ->
send_json(Req1, 200, report_to_json(Report));
{error, not_found} ->
send_error(Req1, 404, <<"Report not found">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_report(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
ReportId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"status">> := NewStatus} ->
case core_report:update_status(ReportId, NewStatus) of
{ok, Report} ->
send_json(Req2, 200, report_to_json(Report));
{error, not_found} ->
send_error(Req2, 404, <<"Report not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing status field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
report_to_json(R) ->
#{
id => R#report.id,
reporter_id => R#report.reporter_id,
target_type => R#report.target_type,
target_id => R#report.target_id,
reason => R#report.reason,
status => R#report.status,
created_at => datetime_to_iso8601(R#report.created_at),
resolved_at => datetime_to_iso8601(R#report.resolved_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,91 @@
-module(admin_handler_reports).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl"). %% ← обязательно для #user{} и #report{}
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_reports(Req);
<<"PUT">> -> update_report(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
list_reports(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
Reports = core_report:list_reports(),
send_json(Req1, 200, [report_to_json(R) || R <- Reports]);
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_report(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
ReportId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"status">> := NewStatus} ->
case core_report:update_status(ReportId, NewStatus) of
{ok, Report} ->
send_json(Req2, 200, report_to_json(Report));
{error, not_found} ->
send_error(Req2, 404, <<"Report not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing status field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
report_to_json(R) ->
#{
id => R#report.id,
reporter_id => R#report.reporter_id,
target_type => R#report.target_type,
target_id => R#report.target_id,
reason => R#report.reason,
status => R#report.status,
created_at => datetime_to_iso8601(R#report.created_at),
resolved_at => datetime_to_iso8601(R#report.resolved_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,97 @@
-module(admin_handler_reviews).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_review(Req);
<<"PUT">> -> update_review(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_review(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
ReviewId = cowboy_req:binding(id, Req1),
case core_review:get_by_id(ReviewId) of
{ok, Review} ->
send_json(Req1, 200, review_to_json(Review));
{error, not_found} ->
send_error(Req1, 404, <<"Review not found">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_review(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
ReviewId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"status">> := NewStatus} ->
case core_review:update_status(ReviewId, NewStatus) of
{ok, Review} ->
send_json(Req2, 200, review_to_json(Review));
{error, not_found} ->
send_error(Req2, 404, <<"Review not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing status field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
review_to_json(R) ->
#{
id => R#review.id,
user_id => R#review.user_id,
target_type => R#review.target_type,
target_id => R#review.target_id,
rating => R#review.rating,
comment => R#review.comment,
status => R#review.status,
created_at => datetime_to_iso8601(R#review.created_at),
updated_at => datetime_to_iso8601(R#review.updated_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,6 +1,5 @@
-module(admin_handler_stats).
-include("records.hrl").
-export([init/2]).
-export([count_users/0, count_calendars/0, count_events/0, count_bookings/0,
count_reviews/0, count_reports/0, count_tickets/0, count_subscriptions/0]).
@@ -41,33 +40,21 @@ get_stats(Req) ->
%% Вспомогательные функции
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} -> User#user.role =:= admin;
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
count_users() ->
length(mnesia:dirty_match_object(#user{_ = '_'})).
count_calendars() ->
length(mnesia:dirty_match_object(#calendar{_ = '_'})).
count_events() ->
length(mnesia:dirty_match_object(#event{is_instance = false, _ = '_'})).
count_bookings() ->
length(mnesia:dirty_match_object(#booking{_ = '_'})).
count_reviews() ->
length(mnesia:dirty_match_object(#review{_ = '_'})).
count_reports() ->
length(mnesia:dirty_match_object(#report{_ = '_'})).
count_tickets() ->
length(mnesia:dirty_match_object(#ticket{_ = '_'})).
count_subscriptions() ->
length(mnesia:dirty_match_object(#subscription{_ = '_'})).
count_users() -> length(mnesia:dirty_match_object(#user{_ = '_'})).
count_calendars() -> length(mnesia:dirty_match_object(#calendar{_ = '_'})).
count_events() -> length(mnesia:dirty_match_object(#event{is_instance = false, _ = '_'})).
count_bookings() -> length(mnesia:dirty_match_object(#booking{_ = '_'})).
count_reviews() -> length(mnesia:dirty_match_object(#review{_ = '_'})).
count_reports() -> length(mnesia:dirty_match_object(#report{_ = '_'})).
count_tickets() -> length(mnesia:dirty_match_object(#ticket{_ = '_'})).
count_subscriptions() -> length(mnesia:dirty_match_object(#subscription{_ = '_'})).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),

View File

@@ -0,0 +1,160 @@
-module(admin_handler_subscriptions).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:binding(id, Req) of
undefined ->
handle_collection(Req);
_SubscriptionId ->
handle_item(Req)
end.
handle_collection(Req) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_subscriptions(Req);
<<"POST">> -> create_subscription(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
handle_item(Req) ->
SubscriptionId = cowboy_req:binding(id, Req),
case cowboy_req:method(Req) of
<<"GET">> -> get_subscription(SubscriptionId, Req);
<<"PUT">> -> update_subscription(SubscriptionId, Req);
<<"DELETE">> -> delete_subscription(SubscriptionId, Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
list_subscriptions(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
Subscriptions = core_subscription:list_subscriptions(),
send_json(Req1, 200, [subscription_to_json(S) || S <- Subscriptions]);
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
create_subscription(Req) ->
case auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"user_id">> := _UserId} = Data ->
SubscriptionData = maps:merge(#{
<<"status">> => <<"active">>,
<<"trial_used">> => false
}, Data),
case core_subscription:create_subscription(SubscriptionData) of
{ok, Subscription} ->
send_json(Req2, 201, subscription_to_json(Subscription));
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Missing 'user_id' field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
get_subscription(SubscriptionId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
case core_subscription:get_by_id(SubscriptionId) of
{ok, Subscription} ->
send_json(Req1, 200, subscription_to_json(Subscription));
{error, not_found} ->
send_error(Req1, 404, <<"Subscription not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_subscription(SubscriptionId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
case core_subscription:update_subscription(SubscriptionId, UpdatesMap) of
{ok, Subscription} ->
send_json(Req2, 200, subscription_to_json(Subscription));
{error, not_found} ->
send_error(Req2, 404, <<"Subscription not found">>);
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
delete_subscription(SubscriptionId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
case core_subscription:delete_subscription(SubscriptionId) of
{ok, deleted} ->
send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
send_error(Req1, 404, <<"Subscription not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
subscription_to_json(S) ->
#{
id => S#subscription.id,
user_id => S#subscription.user_id,
plan => atom_to_binary(S#subscription.plan, utf8),
status => atom_to_binary(S#subscription.status, utf8),
trial_used => S#subscription.trial_used,
started_at => datetime_to_iso8601(S#subscription.started_at),
expires_at => datetime_to_iso8601(S#subscription.expires_at),
created_at => datetime_to_iso8601(S#subscription.created_at),
updated_at => datetime_to_iso8601(S#subscription.updated_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,115 @@
-module(admin_handler_ticket_by_id).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_ticket(Req);
<<"PUT">> -> update_ticket(Req);
<<"DELETE">> -> delete_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_ticket(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
case core_ticket:get_by_id(TicketId) of
{ok, Ticket} ->
send_json(Req1, 200, ticket_to_json(Ticket));
{error, not_found} ->
send_error(Req1, 404, <<"Ticket not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_ticket(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
case core_ticket:update_ticket(TicketId, UpdatesMap) of
{ok, Ticket} ->
send_json(Req2, 200, ticket_to_json(Ticket));
{error, not_found} ->
send_error(Req2, 404, <<"Ticket not found">>);
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
delete_ticket(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
case core_ticket:delete_ticket(TicketId) of
{ok, deleted} ->
send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
send_error(Req1, 404, <<"Ticket not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
ticket_to_json(T) ->
#{
id => T#ticket.id,
error_hash => T#ticket.error_hash,
error_message => T#ticket.error_message,
stacktrace => T#ticket.stacktrace,
context => T#ticket.context,
count => T#ticket.count,
first_seen => datetime_to_iso8601(T#ticket.first_seen),
last_seen => datetime_to_iso8601(T#ticket.last_seen),
status => T#ticket.status,
assigned_to => T#ticket.assigned_to,
resolution_note => T#ticket.resolution_note
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,50 @@
-module(admin_handler_ticket_stats).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl"). % ← добавлено
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_stats(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
Stats = core_ticket:stats(),
send_json(Req1, 200, Stats);
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -0,0 +1,160 @@
-module(admin_handler_tickets).
-behaviour(cowboy_handler).
-export([init/2]).
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:binding(id, Req) of
undefined -> handle_collection(Req);
TicketId -> handle_item(TicketId, Req)
end.
handle_collection(Req) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req);
<<"POST">> -> create_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
handle_item(TicketId, Req) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_ticket(TicketId, Req);
<<"PUT">> -> update_ticket(TicketId, Req);
<<"DELETE">> -> delete_ticket(TicketId, Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
list_tickets(Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
Tickets = core_ticket:list_tickets(),
send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]);
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
create_ticket(Req) ->
case auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"error_message">> := _} = Data ->
% Администратор может указать error_hash, stacktrace, context, status
TicketData = maps:merge(#{
<<"status">> => <<"open">>,
<<"assigned_to">> => undefined
}, Data),
case core_ticket:create_ticket(TicketData) of
{ok, Ticket} ->
send_json(Req2, 201, ticket_to_json(Ticket));
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Missing 'error_message' field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
get_ticket(TicketId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
case core_ticket:get_by_id(TicketId) of
{ok, Ticket} ->
send_json(Req1, 200, ticket_to_json(Ticket));
{error, not_found} ->
send_error(Req1, 404, <<"Ticket not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
update_ticket(TicketId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
case core_ticket:update_ticket(TicketId, UpdatesMap) of
{ok, Ticket} ->
send_json(Req2, 200, ticket_to_json(Ticket));
{error, not_found} ->
send_error(Req2, 404, <<"Ticket not found">>);
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
delete_ticket(TicketId, Req) ->
case auth_admin(Req) of
{ok, _AdminId, Req1} ->
case core_ticket:delete_ticket(TicketId) of
{ok, deleted} ->
send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
send_error(Req1, 404, <<"Ticket not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Message, Req1} ->
{error, Code, Message, Req1}
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Role = User#user.role,
Role =:= admin orelse Role =:= superadmin orelse
Role =:= moderator orelse Role =:= support;
_ -> false
end.
ticket_to_json(T) ->
#{
id => T#ticket.id,
error_hash => T#ticket.error_hash,
error_message => T#ticket.error_message,
stacktrace => T#ticket.stacktrace,
context => T#ticket.context,
count => T#ticket.count,
first_seen => datetime_to_iso8601(T#ticket.first_seen),
last_seen => datetime_to_iso8601(T#ticket.last_seen),
status => T#ticket.status,
assigned_to => T#ticket.assigned_to,
resolution_note => T#ticket.resolution_note
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,6 +1,5 @@
-module(admin_handler_user_by_id).
-include("records.hrl").
-export([init/2]).
-export([user_to_json/1, convert_updates/1]).
@@ -9,10 +8,10 @@ init(Req, Opts) ->
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_user(Req);
<<"PUT">> -> update_user(Req);
<<"GET">> -> get_user(Req);
<<"PUT">> -> update_user(Req);
<<"DELETE">> -> delete_user(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_user(Req) ->
@@ -22,11 +21,10 @@ get_user(Req) ->
true ->
UserId = cowboy_req:binding(id, Req1),
case core_user:get_by_id(UserId) of
{ok, User} when User#user.status =:= deleted ->
send_error(Req1, 404, <<"User not found">>);
{ok, User} ->
case User#user.status of
deleted -> send_error(Req1, 404, <<"User not found">>);
_ -> send_json(Req1, 200, user_to_json(User))
end;
send_json(Req1, 200, user_to_json(User));
{error, not_found} ->
send_error(Req1, 404, <<"User not found">>)
end;
@@ -47,9 +45,8 @@ update_user(Req) ->
try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) ->
Updates = maps:to_list(Decoded),
% Преобразуем бинарные значения в атомы где нужно
ConvertedUpdates = convert_updates(Updates),
case core_user:update(UserId, ConvertedUpdates) of
Converted = convert_updates(Updates),
case core_user:update(UserId, Converted) of
{ok, User} ->
send_json(Req2, 200, user_to_json(User));
{error, not_found} ->
@@ -69,16 +66,6 @@ update_user(Req) ->
send_error(Req1, Code, Message)
end.
convert_updates(Updates) ->
lists:map(fun
({<<"status">>, <<"active">>}) -> {status, active};
({<<"status">>, <<"frozen">>}) -> {status, frozen};
({<<"status">>, <<"deleted">>}) -> {status, deleted};
({<<"role">>, <<"user">>}) -> {role, user};
({<<"role">>, <<"admin">>}) -> {role, admin};
(Other) -> Other
end, Updates).
delete_user(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
@@ -106,10 +93,10 @@ is_admin(UserId) ->
user_to_json(User) ->
#{
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
created_at => datetime_to_iso8601(User#user.created_at),
updated_at => datetime_to_iso8601(User#user.updated_at)
}.
@@ -118,6 +105,13 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
convert_updates(Updates) ->
lists:map(fun
({<<"status">>, Value}) -> {status, binary_to_existing_atom(Value)};
({<<"role">>, Value}) -> {role, binary_to_existing_atom(Value)};
(Other) -> Other
end, Updates).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),

View File

@@ -1,47 +1,30 @@
-module(admin_handler_users).
-include("records.hrl").
-behaviour(cowboy_handler).
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_users(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
list_users(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
Response = [user_to_json(U) || U <- ActiveUsers],
<<"GET">> ->
case handler_auth:authenticate(Req) of
{ok, _UserId, Req1} ->
{ok, Users} = core_user:list_users(),
Response = [user_to_map(U) || U <- Users],
send_json(Req1, 200, Response);
false ->
send_error(Req1, 403, <<"Admin access required">>)
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} -> User#user.role =:= admin;
_ -> false
end.
user_to_json(User) ->
user_to_map(User) ->
#{
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
created_at => datetime_to_iso8601(User#user.created_at),
updated_at => datetime_to_iso8601(User#user.updated_at)
id => maps:get(id, User),
email => maps:get(email, User),
role => maps:get(role, User),
status => maps:get(status, User),
created_at => datetime_to_iso8601(maps:get(created_at, User)),
updated_at => datetime_to_iso8601(maps:get(updated_at, User))
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->

View File

@@ -1,132 +0,0 @@
-module(handler_admin_moderation).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"PUT">> -> moderate(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
%% PUT /v1/admin/:target_type/:id - заморозка/разморозка
moderate(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
TargetTypeBin = cowboy_req:binding(target_type, Req1),
TargetId = cowboy_req:binding(id, Req1),
TargetType = parse_target_type(TargetTypeBin),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"action">> := Action} ->
case {TargetType, Action} of
{calendar, <<"freeze">>} ->
case logic_moderation:freeze_calendar(AdminId, TargetId) of
{ok, Calendar} ->
send_json(Req2, 200, calendar_to_json(Calendar));
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Calendar not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
{calendar, <<"unfreeze">>} ->
case logic_moderation:unfreeze_calendar(AdminId, TargetId) of
{ok, Calendar} ->
send_json(Req2, 200, calendar_to_json(Calendar));
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Calendar not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
{event, <<"freeze">>} ->
case logic_moderation:freeze_event(AdminId, TargetId) of
{ok, Event} ->
send_json(Req2, 200, event_to_json(Event));
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Event not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
{event, <<"unfreeze">>} ->
case logic_moderation:unfreeze_event(AdminId, TargetId) of
{ok, Event} ->
send_json(Req2, 200, event_to_json(Event));
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Event not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Invalid target_type or action">>)
end;
_ ->
send_error(Req2, 400, <<"Missing action field">>)
catch
_:_ ->
send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
%% Вспомогательные функции
parse_target_type(<<"calendars">>) -> calendar;
parse_target_type(<<"events">>) -> event;
parse_target_type(_) -> undefined.
calendar_to_json(Calendar) ->
#{
id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
description => Calendar#calendar.description,
type => Calendar#calendar.type,
tags => Calendar#calendar.tags,
status => Calendar#calendar.status,
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
created_at => datetime_to_iso8601(Calendar#calendar.created_at),
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
}.
event_to_json(Event) ->
#{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
title => Event#event.title,
description => Event#event.description,
event_type => Event#event.event_type,
start_time => datetime_to_iso8601(Event#event.start_time),
duration => Event#event.duration,
status => Event#event.status,
tags => Event#event.tags,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
created_at => datetime_to_iso8601(Event#event.created_at),
updated_at => datetime_to_iso8601(Event#event.updated_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,93 +0,0 @@
-module(handler_admin_reviews).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"PUT">> -> moderate_review(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
%% PUT /v1/admin/reviews/:id - скрыть/раскрыть отзыв
moderate_review(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
% Проверим роль
case core_user:get_by_id(AdminId) of
{ok, User} ->
io:format("User ~p role: ~p~n", [AdminId, User#user.role]);
_ -> ok
end,
ReviewId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"action">> := Action} ->
case Action of
<<"hide">> ->
case logic_review:hide_review(AdminId, ReviewId) of
{ok, Review} ->
Response = review_to_json(Review),
send_json(Req2, 200, Response);
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Review not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
<<"unhide">> ->
case logic_review:unhide_review(AdminId, ReviewId) of
{ok, Review} ->
Response = review_to_json(Review),
send_json(Req2, 200, Response);
{error, access_denied} ->
send_error(Req2, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req2, 404, <<"Review not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Invalid action. Use 'hide' or 'unhide'">>)
end;
_ ->
send_error(Req2, 400, <<"Missing action field">>)
catch
_:_ ->
send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
%% Вспомогательные функции
review_to_json(Review) ->
#{
id => Review#review.id,
user_id => Review#review.user_id,
target_type => Review#review.target_type,
target_id => Review#review.target_id,
rating => Review#review.rating,
comment => Review#review.comment,
status => Review#review.status,
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,84 +0,0 @@
-module(handler_admin_subscriptions).
-include("records.hrl").
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_subscriptions(Req);
<<"DELETE">> -> cancel_subscription(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
%% GET /v1/admin/subscriptions - список всех подписок
list_subscriptions(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
case is_admin(AdminId) of
true ->
{ok, Subscriptions} = core_subscription:list_all(),
Response = [subscription_to_json(S) || S <- Subscriptions],
send_json(Req1, 200, Response);
false ->
send_error(Req1, 403, <<"Admin access required">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
%% DELETE /v1/admin/subscriptions/:id - отменить подписку
cancel_subscription(Req) ->
case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} ->
SubscriptionId = cowboy_req:binding(id, Req1),
case logic_subscription:cancel_subscription(AdminId, SubscriptionId) of
{ok, Subscription} ->
Response = subscription_to_json(Subscription),
send_json(Req1, 200, Response);
{error, access_denied} ->
send_error(Req1, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req1, 404, <<"Subscription not found">>);
{error, _} ->
send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
%% Вспомогательные функции
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} -> User#user.role =:= admin;
_ -> false
end.
subscription_to_json(Subscription) ->
#{
id => Subscription#subscription.id,
user_id => Subscription#subscription.user_id,
plan => Subscription#subscription.plan,
status => Subscription#subscription.status,
trial_used => Subscription#subscription.trial_used,
started_at => datetime_to_iso8601(Subscription#subscription.started_at),
expires_at => datetime_to_iso8601(Subscription#subscription.expires_at),
created_at => datetime_to_iso8601(Subscription#subscription.created_at),
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
}.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,10 +1,11 @@
-module(handler_login).
-include("records.hrl").
-behaviour(cowboy_handler).
-export([init/2]).
init(Req, Opts) ->
handle(Req, Opts).
-include("records.hrl"). %% ← необходим для #session{}
init(Req0, State) ->
handle(Req0, State).
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
@@ -18,41 +19,31 @@ handle(Req, _Opts) ->
_ ->
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case core_user:get_by_email(Email) of
{ok, User} ->
case logic_auth:verify_password(Password, User#user.password_hash) of
{ok, true} ->
case User#user.status of
active ->
Token = logic_auth:generate_jwt(User#user.id, User#user.role),
{RefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id),
save_refresh_token(User#user.id, RefreshToken, ExpiresAt),
Response = #{
user => #{
id => User#user.id,
email => User#user.email,
role => User#user.role
},
token => Token,
refresh_token => RefreshToken
},
send_json(Req1, 200, Response);
frozen ->
send_error(Req1, 403, <<"Account frozen">>);
deleted ->
send_error(Req1, 403, <<"Account deleted">>)
end;
_ ->
send_error(Req1, 401, <<"Invalid credentials">>)
end;
{error, not_found} ->
case auth:authenticate_user_request(Req1, Email, Password) of
{ok, Token, User} ->
{RefreshToken, ExpiresAt} = auth:generate_refresh_token(maps:get(id, User)),
save_refresh_token(maps:get(id, User), RefreshToken, ExpiresAt),
Response = #{
user => #{
id => maps:get(id, User),
email => maps:get(email, User),
role => maps:get(role, User)
},
token => Token,
refresh_token => RefreshToken
},
send_json(Req1, 200, Response);
{error, frozen} ->
send_error(Req1, 403, <<"Account frozen">>);
{error, deleted} ->
send_error(Req1, 403, <<"Account deleted">>);
{error, _Reason} ->
send_error(Req1, 401, <<"Invalid credentials">>)
end;
_ ->
send_error(Req1, 400, <<"Missing email or password">>)
catch
_:_ ->
send_error(Req1, 400, <<"Invalid JSON">>)
_:_ -> send_error(Req1, 400, <<"Invalid JSON">>)
end
end;
false ->
@@ -63,7 +54,7 @@ handle(Req, _Opts) ->
end.
save_refresh_token(UserId, Token, ExpiresAt) ->
Session = #session{
Session = #session{ %% record определён в records.hrl
token = Token,
user_id = UserId,
expires_at = ExpiresAt,
@@ -73,10 +64,14 @@ save_refresh_token(UserId, Token, ExpiresAt) ->
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
cowboy_req:reply(Status, #{
<<"content-type">> => <<"application/json">>
}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
cowboy_req:reply(Status, #{
<<"content-type">> => <<"application/json">>
}, Body, Req),
{ok, Body, []}.

157
src/infra/auth.erl Normal file
View File

@@ -0,0 +1,157 @@
-module(auth).
-export([
generate_user_token/2,
generate_admin_token/2,
verify_user_token/1,
verify_admin_token/1,
authenticate_user_request/3,
authenticate_admin_request/3,
generate_refresh_token/1
]).
%% ========== КОНФИГУРАЦИЯ СЕКРЕТОВ ==========
-spec get_user_secret() -> binary().
get_user_secret() ->
case application:get_env(eventhub, jwt_secret) of
{ok, Secret} when is_binary(Secret) -> Secret;
undefined -> get_user_secret_from_env()
end.
get_user_secret_from_env() ->
case os:getenv("JWT_SECRET") of
false -> <<"user-secret-key-32-bytes-minimum!">>;
S -> list_to_binary(S)
end.
-spec get_admin_secret() -> binary().
get_admin_secret() ->
case application:get_env(eventhub, admin_jwt_secret) of
{ok, Secret} when is_binary(Secret) -> Secret;
undefined -> get_admin_secret_from_env()
end.
get_admin_secret_from_env() ->
case os:getenv("ADMIN_JWT_SECRET") of
false -> <<"admin-secret-key-32-bytes-minimum!">>;
S -> list_to_binary(S)
end.
-spec get_user_jwk() -> jose_jwk:key().
get_user_jwk() -> jose_jwk:from_oct(get_user_secret()).
-spec get_admin_jwk() -> jose_jwk:key().
get_admin_jwk() -> jose_jwk:from_oct(get_admin_secret()).
%% ========== ГЕНЕРАЦИЯ ТОКЕНОВ ==========
-spec generate_user_token(UserId :: binary(), Role :: binary()) -> binary().
generate_user_token(UserId, Role) ->
generate_token(get_user_jwk(), UserId, Role, <<"user">>).
-spec generate_admin_token(UserId :: binary(), Role :: binary()) -> binary().
generate_admin_token(UserId, Role) ->
generate_token(get_admin_jwk(), UserId, Role, <<"admin">>).
generate_token(JWK, UserId, Role, Audience) ->
ExpTime = erlang:system_time(second) + 86400,
Claims = #{
<<"user_id">> => UserId,
<<"role">> => Role,
<<"aud">> => Audience,
<<"exp">> => ExpTime,
<<"iat">> => erlang:system_time(second)
},
JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims),
{_, Token} = jose_jws:compact(JWT),
Token.
%% ========== ПРОВЕРКА ТОКЕНОВ ==========
-spec verify_user_token(Token :: binary()) ->
{ok, UserId :: binary(), Role :: binary()} | {error, atom()}.
verify_user_token(Token) ->
verify_token(get_user_jwk(), Token, <<"user">>).
-spec verify_admin_token(Token :: binary()) ->
{ok, UserId :: binary(), Role :: binary()} | {error, atom()}.
verify_admin_token(Token) ->
verify_token(get_admin_jwk(), Token, <<"admin">>).
verify_token(JWK, Token, ExpectedAud) ->
try
case jose_jwt:verify(JWK, Token) of
{true, {jose_jwt, Claims}, _} ->
validate_claims(Claims, ExpectedAud);
{true, Claims, _} when is_map(Claims) ->
validate_claims(Claims, ExpectedAud);
_ ->
{error, invalid_signature}
end
catch
_:_ -> {error, invalid_token}
end.
validate_claims(Claims, ExpectedAud) ->
case maps:find(<<"aud">>, Claims) of
{ok, ExpectedAud} ->
case maps:find(<<"exp">>, Claims) of
{ok, Exp} when is_integer(Exp) ->
Now = erlang:system_time(second),
if
Exp > Now ->
UserId = maps:get(<<"user_id">>, Claims, undefined),
Role = maps:get(<<"role">>, Claims, <<"user">>),
{ok, UserId, Role};
true ->
{error, expired}
end;
{ok, _Exp} -> {error, expired};
_ -> {error, no_expiration}
end;
{ok, _} -> {error, invalid_audience};
error -> {error, missing_audience}
end.
%% ========== АУТЕНТИФИКАЦИЯ ЗАПРОСА ==========
-spec authenticate_user_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) ->
{ok, Token :: binary(), User :: map()} | {error, atom()}.
authenticate_user_request(_Req, Email, Password) ->
case logic_auth:authenticate_user(Email, Password) of
{ok, User} ->
UserId = maps:get(id, User),
Role = maps:get(role, User, <<"user">>),
Token = generate_user_token(UserId, Role),
{ok, Token, User};
Error -> Error
end.
-spec authenticate_admin_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) ->
{ok, Token :: binary(), User :: map()} | {error, atom()}.
authenticate_admin_request(_Req, Email, Password) ->
case logic_auth:authenticate_user(Email, Password) of
{ok, User} ->
Role = maps:get(role, User, <<"admin">>),
case is_admin_role(Role) of
true ->
UserId = maps:get(id, User),
Token = generate_admin_token(UserId, Role),
{ok, Token, User};
false -> {error, insufficient_permissions}
end;
Error -> Error
end.
%% ========== REFRESH TOKEN ==========
-spec generate_refresh_token(UserId :: binary()) -> {binary(), integer()}.
generate_refresh_token(_UserId) ->
RefreshToken = base64:encode(crypto:strong_rand_bytes(32)),
ExpiresAt = erlang:system_time(second) + 2592000, % 30 дней
{RefreshToken, ExpiresAt}.
%% ========== ВНУТРЕННИЕ ==========
is_admin_role(Role) ->
lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]).