Compare commits

...

140 Commits

Author SHA1 Message Date
95692a6a1f build: update ref
All checks were successful
.NET Test Pipeline / build (push) Successful in 2m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m55s
2025-03-11 09:37:58 +03:00
13eb3c0033 fix: remove convert universal time
All checks were successful
.NET Test Pipeline / build (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m30s
2025-02-14 17:47:56 +03:00
f6d1543108 docs: fix code character
Some checks failed
.NET Test Pipeline / build (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Has been cancelled
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-13 16:40:33 +03:00
a4721c9739 build: add configuration Release
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
.NET Test Pipeline / build (push) Successful in 1m51s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-12 09:35:55 +03:00
46bbc34956 refactor: fix error WHITESPACE and FINALNEWLINE
All checks were successful
.NET Test Pipeline / build (pull_request) Successful in 1m37s
.NET Test Pipeline / build (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m18s
2025-02-11 17:13:13 +03:00
047ccfa754 fix: correct calculate next occurrence
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 44s
2025-02-11 16:35:56 +03:00
b0d9a67c1c refacotr: clean code 2025-02-11 15:36:55 +03:00
3eb043b24c build: fix code style with CRLF
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 42s
2025-02-11 15:29:43 +03:00
4cd476764d build: fix secrets
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 43s
2025-02-11 15:25:47 +03:00
90b4662dda Release 1.0.0
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 8s
2025-02-11 15:16:51 +03:00
e7edc79ebc build: instead build run analyze 2025-02-11 15:04:38 +03:00
aabeed0aa5 feat: add backend version to swagger 2025-02-10 16:07:51 +03:00
e79ec360ea Merge branch 'release/v1.0.0' of https://git.winsomnia.net/Winsomnia/MireaBackend into release/v1.0.0
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m36s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m37s
2025-02-06 16:29:48 +03:00
31c1d2804d fix: hotfix calculate next run time 2025-02-06 16:27:20 +03:00
ea4c8b61e0 refactor: use thread pool instead task
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m31s
2025-02-03 11:25:39 +03:00
b40e394bcf fix: System.ObjectDisposedException for db context into sync secrvice
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m45s
2025-02-03 10:55:47 +03:00
885b937b0b feat: add parsing from files
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 49s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m42s
2025-02-03 03:44:40 +03:00
dc08285ec8 feat: clear old records 2025-02-02 20:31:52 +03:00
b3a0964aac fix: correct filter data 2025-02-02 20:28:04 +03:00
7d6b21c5bb fix: move from body to query 2025-02-02 04:51:09 +03:00
93912caf01 fix: return correct value 2025-02-02 04:50:54 +03:00
c725cfed32 refactor: increase max value 2025-02-02 04:50:35 +03:00
7c7707b1e2 fix: if delay more than int set max of int 2025-02-02 04:50:04 +03:00
1687e9d89b fix: continue if in filter exist value 2025-02-02 04:49:25 +03:00
8d1b709b43 feat: add start term update and cron schedule update 2025-02-02 03:39:30 +03:00
ce6b0f2673 feat: add cron skipping date 2025-02-02 03:30:52 +03:00
16afc0bc69 feat: show enum name instead value 2025-02-02 03:29:19 +03:00
c9bc6a3565 refactor: remove "swagger" in class name 2025-02-02 03:28:24 +03:00
ad8f356fc1 fix: get non negative number 2025-02-02 01:57:08 +03:00
dda0a29300 refactor: subscribe to onChange instead of waiting for the event to be received from the manager 2025-02-01 21:23:51 +03:00
369901db78 fix: set long, because the value may be greater than int 2025-02-01 21:19:56 +03:00
a67b72b7fb refactor: rename cancellation to cancellationToken 2025-02-01 21:18:56 +03:00
2453b2bd51 build: upgrade ref 2025-02-01 20:47:25 +03:00
5870eef552 feat: add a tag schema to combine similar controllers. 2025-02-01 20:47:08 +03:00
52de98969d refactor: remove unused brackets 2025-02-01 20:45:08 +03:00
bc86e077bd refactor: move to SetupConfiguration namespace 2025-02-01 19:39:02 +03:00
03b6560bc4 feat: add lesson type controller 2025-02-01 17:08:00 +03:00
5bcb7bfbc1 feat: allow filter by lesson type 2025-02-01 17:06:02 +03:00
38fba5556f feat: add filter by type of occupation (lesson type) 2025-02-01 16:46:20 +03:00
fd26178a24 build: update ref
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m54s
2025-01-24 17:12:39 +03:00
7eb307b65e fix: return empty string if null 2025-01-24 17:10:46 +03:00
56c7196100 refactor: change const name to class with name 2025-01-24 17:10:18 +03:00
92081156cf fix: save token after update
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m13s
2024-12-28 08:34:19 +03:00
6358410f18 sec: to establish the ownership of the token for the first one who received it 2024-12-28 08:30:56 +03:00
e79ddf220f sec: set the absolute time of the token 2024-12-28 08:29:31 +03:00
c3c9844e2f refactor: improve logging 2024-12-28 08:29:06 +03:00
206720cd63 fix: add force select account 2024-12-28 08:16:00 +03:00
d9f4176aca fix: return message if 401 2024-12-28 08:15:43 +03:00
1de344ac25 refactor: to enable oauth during registration, use the appropriate controller. 2024-12-28 07:46:06 +03:00
61a11ea223 fix: return exception message if controller exception 2024-12-28 06:47:21 +03:00
07111b9b61 sec: do not return the error text to the user
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m40s
.NET Test Pipeline / build-and-test (push) Successful in 1m18s
2024-12-26 16:40:30 +03:00
538f1d67c8 fix: change the link to the error type 2024-12-26 16:39:29 +03:00
233458ed89 refactor: add standard traceId 2024-12-26 16:38:53 +03:00
7f87b4d856 style: add space between point and provider 2024-12-26 16:38:13 +03:00
0c6d1c9bfb refactor: compact two factor auth 2024-12-26 16:16:33 +03:00
516ba5bb8e feat: add a token handler 2024-12-26 16:14:55 +03:00
9d5007ef3a refactor: add user converter 2024-12-26 16:14:28 +03:00
c75ac60b0b sec: add verification for OAuth authorization 2024-12-26 15:47:38 +03:00
5b7412f20f feat: return the provider 2024-12-26 15:46:55 +03:00
c4a4478b8c refactor: standardize the order of arguments 2024-12-26 15:46:30 +03:00
05166188be feat: add a method for getting info about a token 2024-12-26 14:32:28 +03:00
157708d00f feat: store the result at each stage 2024-12-26 14:18:12 +03:00
36026b3afb refactor: distribute the domain folder 2024-12-26 13:38:43 +03:00
43edab2912 sec: return the token instead of performing actions with the user
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m0s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m53s
2024-12-26 08:51:22 +03:00
dcdd43469b feat: add payload model 2024-12-26 08:48:17 +03:00
17fd260068 sec: add payload 2024-12-26 08:47:56 +03:00
97187a8e45 sec: save readonly byte array instead string 2024-12-26 08:44:05 +03:00
cfe08dcf9b refactor: get interface instead array 2024-12-26 08:42:24 +03:00
ae4d2073c4 feat: add a 200 result schema 2024-12-25 07:22:07 +03:00
269d976ad4 refactor: move arguments to a new line 2024-12-25 05:54:27 +03:00
5fa545e981 fix: trim the variable to avoid the effects of whitespace characters 2024-12-25 05:53:59 +03:00
2ab5dea8ba feat: add a change to the User Agent and Ip address in case of a mismatch 2024-12-25 05:52:39 +03:00
5e65aded79 refactor: instead of Reason, add explicit arguments 2024-12-25 05:51:54 +03:00
dfac9ddca8 sec: add failed attempts for 2FA 2024-12-25 05:49:13 +03:00
c66f3355ec feat: add logging for empty secret 2024-12-25 05:48:22 +03:00
c12323dc29 refactor: rename methods to match the context 2024-12-25 05:47:51 +03:00
71c31c0bbb refactor: separate the method of counting failed attempts 2024-12-25 05:46:27 +03:00
8c51ba83a4 fix: add trim for email and username 2024-12-25 05:44:37 +03:00
9ff0f51e19 refactor: add data annotations 2024-12-25 05:44:15 +03:00
408a95e4b3 refactor: add .editorconfig and refactor code 2024-12-25 05:43:30 +03:00
2a33ecbf07 build: fix deploy script
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m19s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m14s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 16:54:05 +03:00
97e50b5331 fix: set minimum level for authorization and authentication to warning
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m31s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 10:53:32 +03:00
d505041c72 fix: escape data for state
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m38s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m9s
2024-12-23 08:10:19 +03:00
5ff8744a55 feat: improve logging
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m12s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m11s
2024-12-23 07:48:28 +03:00
053f01eec1 fix: hotfix getting current port
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m35s
2024-12-23 06:56:01 +03:00
e8e94e45a5 fix: add missing using
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m21s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m21s
2024-12-23 06:32:08 +03:00
55562a9f00 feat: add default response type
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Failing after 1m30s
.NET Test Pipeline / build-and-test (push) Failing after 1m7s
2024-12-23 06:29:29 +03:00
57b9819d13 feat: add maintenance ignore 2024-12-23 06:29:00 +03:00
78254ed23d fix: change same name 2024-12-23 06:28:28 +03:00
202d20bb25 build: add providers 2024-12-23 06:28:06 +03:00
3e05863aea refactor: clean code 2024-12-22 07:25:41 +03:00
b82fbc491f fix: add missing using
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m24s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m14s
2024-12-22 07:21:51 +03:00
85722f8552 feat: add integration with seq
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m43s
Build and Deploy Docker Container / build-and-deploy (push) Failing after 2m6s
2024-12-22 07:13:59 +03:00
9231c4d5ca refactor: remove unused code 2024-12-22 06:39:12 +03:00
7b94f9cc1f refactor: for maintenance mode, return the standard error 2024-12-22 05:51:11 +03:00
7bafbb95c4 build: update ref 2024-12-22 05:27:07 +03:00
544ad6e791 feat: add an annotation to the data 2024-12-22 05:26:10 +03:00
e4b942d062 refactor: to return the result according to the RFC 7807 standard and add a traceId 2024-12-22 05:25:19 +03:00
f2e79e51f2 sec: transfer user verification to the appropriate service 2024-12-22 05:13:46 +03:00
5cc54eac44 feat: add a method for getting data from OAuth
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m20s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m24s
2024-12-18 07:40:07 +03:00
e9ff1cabe8 feat: add a return of the data that has been configured 2024-12-18 07:39:17 +03:00
fd578aa61e fix: add condition for token 2024-12-18 07:32:00 +03:00
cff42d0a31 feat: add password policy to general config 2024-12-18 07:31:16 +03:00
8250957b85 refactor: use another origin 2024-12-18 07:30:47 +03:00
39208037f0 fix: add method for ignoring mode attribute 2024-12-18 07:30:23 +03:00
5e072d88c2 fix: converter dto 2024-12-18 07:29:58 +03:00
25eddbe776 feat: add generator totp qr-code 2024-12-18 07:29:31 +03:00
74ba4e901a fix: replace localhost to 127.0.0.1 2024-12-18 07:29:05 +03:00
e760ddae0a feat: give the user the ability to make a password policy 2024-12-18 07:27:57 +03:00
598ebabc5c sec: use HMAC to encrypt state 2024-12-18 07:24:33 +03:00
08aeb7ea3c sec: get links to the backend to initiate the receipt of provider data 2024-12-18 07:23:23 +03:00
182235c4cd feat: add generator base32 for totp 2024-12-18 07:14:04 +03:00
5437623a20 build: update ref 2024-12-18 07:13:27 +03:00
2c09122971 fix: add cookie expire time 2024-11-04 03:15:13 +03:00
503f5792fb docs: add comment 2024-11-04 03:14:42 +03:00
95627003e5 refactor: change the error 2024-11-04 03:14:17 +03:00
a96073d44d feat: add available providers list 2024-11-04 02:59:51 +03:00
5f36e0f75b docs: update 2024-11-04 02:39:45 +03:00
e977de3e4f feat: add authorize in OAuth 2024-11-04 02:39:10 +03:00
65d928ec2d fix: remove authorize 2024-11-04 02:36:22 +03:00
713bbfa16f feat: add calculate correct api url 2024-11-04 02:35:43 +03:00
6b5eda7756 fix: remove the latest api 2024-11-04 02:34:50 +03:00
dbd9e1a070 refactor: change Name to NameIdentifier 2024-11-04 02:33:56 +03:00
0dda336de1 fix: logout for all users to delete cookies 2024-11-04 02:32:13 +03:00
727f5c276e refactor: move files 2024-11-02 23:34:23 +03:00
db70e4dd96 refactor: change log text 2024-11-02 22:10:46 +03:00
6831d9c708 fix: return bool instead 2024-11-02 22:09:40 +03:00
1b24954c3e refactor: change int to string for Id 2024-11-02 20:21:46 +03:00
c5ba1cfcca refactor: transfer two factor method to security 2024-11-02 01:09:15 +03:00
3811d879ab refactor: return next step from security 2024-11-02 01:06:58 +03:00
61dc0a8bc4 feat: add converter for two factor 2024-11-02 01:05:24 +03:00
b3b00aa9e1 refator: move converter to MapperDto 2024-11-02 00:59:37 +03:00
6c9af942f4 refactor: change token to instance token 2024-11-02 00:51:27 +03:00
23f74b3bdf refactor: change name enums 2024-11-02 00:50:10 +03:00
eb272baa38 fix: change cookie name
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m12s
.NET Test Pipeline / build-and-test (push) Successful in 2m44s
2024-10-31 04:23:43 +03:00
a0ff624481 fix: add forgotten changes
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m39s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m11s
2024-10-31 04:12:22 +03:00
cd6f25deba refactor: transfer logic
All logic related to token manipulation has been transferred to the AuthService. Also added TOTP 2FA and rethought the logic of logging into the application
2024-10-31 04:12:02 +03:00
0f47a98ad9 feat: return security exception 2024-10-31 04:07:35 +03:00
3279ef594b fix: change current culture to russian for import 2024-10-31 04:06:58 +03:00
5bc729eb66 fix: add an implementation for saving primitive data 2024-10-31 04:05:40 +03:00
156 changed files with 4040 additions and 743 deletions

278
.editorconfig Normal file
View File

@ -0,0 +1,278 @@
# Удалите строку ниже, если вы хотите наследовать параметры .editorconfig из каталогов, расположенных выше в иерархии
root = true
# Файлы C#
[*.cs]
#### Основные параметры EditorConfig ####
# Отступы и интервалы
indent_size = 4
indent_style = space
tab_width = 4
# Предпочтения для новых строк
end_of_line = unset
insert_final_newline = false
#### Действия кода .NET ####
# Члены типа
dotnet_hide_advanced_members = false
dotnet_member_insertion_location = with_other_members_of_the_same_kind
dotnet_property_generation_behavior = prefer_throwing_properties
# Поиск символов
dotnet_search_reference_assemblies = true
#### Рекомендации по написанию кода .NET ####
# Упорядочение Using
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# Предпочтения для this. и Me.
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Параметры использования ключевых слов языка и типов BCL
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Предпочтения для скобок
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Предпочтения модификатора
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Выражения уровень предпочтения
dotnet_prefer_system_hash_code = true
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Предпочтения для полей
dotnet_style_readonly_field = true
# Настройки параметров
dotnet_code_quality_unused_parameters = non_public
# Параметры подавления
dotnet_remove_unnecessary_suppression_exclusions = none
# Предпочтения для новых строк
dotnet_style_allow_multiple_blank_lines_experimental = true
dotnet_style_allow_statement_immediately_after_block_experimental = false
#### Рекомендации по написанию кода C# ####
# Предпочтения var
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true:silent
csharp_style_var_when_type_is_apparent = true:silent
# Члены, заданные выражениями
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = true:silent
csharp_style_expression_bodied_methods = true:silent
csharp_style_expression_bodied_operators = true:silent
csharp_style_expression_bodied_properties = true:silent
# Настройки соответствия шаблонов
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Настройки проверки на null
csharp_style_conditional_delegate_call = true:suggestion
# Предпочтения модификатора
csharp_prefer_static_anonymous_function = true:suggestion
csharp_prefer_static_local_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
# Предпочтения для блоков кода
csharp_prefer_braces = when_multiline:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_system_threading_lock = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = false:silent
# Выражения уровень предпочтения
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# предпочтения для директивы using
csharp_using_directive_placement = outside_namespace:silent
# Предпочтения для новых строк
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:silent
csharp_style_allow_embedded_statements_on_same_line_experimental = false:silent
#### Правила форматирования C# ####
# Предпочтения для новых строк
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Предпочтения для отступов
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Предпочтения для интервалов
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Предпочтения переноса
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Стили именования ####
# Правила именования
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Спецификации символов
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Стили именования
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = unset
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_code_quality_unused_parameters = non_public:suggestion
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
dotnet_style_allow_statement_immediately_after_block_experimental = false:silent
dotnet_style_readonly_field = true:suggestion
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent

48
.env
View File

@ -21,6 +21,8 @@ PATH_TO_SAVE=
# The actual sub path to the api
# string
# (optional)
# If the specified path ends with "/api", the system will avoid duplicating "api" in the final URL.
# This allows flexible API structuring, especially when running behind a reverse proxy or in containerized environments.
ACTUAL_SUB_PATH=
# The sub path to the swagger
@ -114,4 +116,48 @@ SECURITY_HASH_TOKEN=
# The size of the salt used to hash passwords
# integer
# The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks.
SECURITY_SALT_SIZE=16
SECURITY_SALT_SIZE=16
### OAuth2
#### GOOGLE
# The client ID for Google OAuth
# string
# This is the client ID provided by Google when you register your application for OAuth.
# It's necessary for enabling Google login functionality.
GOOGLE_CLIENT_ID=
# The client secret for Google OAuth
# string
# This is the client secret provided by Google, used alongside the client ID to authenticate your application.
# Make sure to keep it confidential.
GOOGLE_CLIENT_SECRET=
#### Yandex
# The client ID for Yandex OAuth
# string
# This is the client ID provided by Yandex when you register your application for OAuth.
# It's required for enabling Yandex login functionality.
YANDEX_CLIENT_ID=
# The client secret for Yandex OAuth
# string
# This is the client secret provided by Yandex, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
YANDEX_CLIENT_SECRET=
#### MailRu
# The client ID for MailRu OAuth
# string
# This is the client ID provided by MailRu (Mail.ru Group) when you register your application for OAuth.
# It's necessary for enabling MailRu login functionality.
MAILRU_CLIENT_ID=
# The client secret for MailRu OAuth
# string
# This is the client secret provided by MailRu, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
MAILRU_CLIENT_SECRET=

View File

@ -1,29 +0,0 @@
name: .NET Test Pipeline
on:
pull_request:
push:
branches:
[master, 'release/*']
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build the solution
run: dotnet build --configuration Release
- name: Run tests
run: dotnet test --configuration Release --no-build --no-restore --verbosity normal

31
.github/workflows/code-analyze.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
name: .NET Test Pipeline
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarScanner for .NET 8 with pull request decoration support
uses: highbyte/sonarscan-dotnet@v2.3.3
with:
sonarProjectKey: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarProjectName: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarHostname: ${{ secrets.SONAR_HOST_URL }}
dotnetPreBuildCmd: dotnet nuget add source --name="Winsomnia" --username ${{ secrets.NUGET_USERNAME }} --password ${{ secrets.NUGET_PASSWORD }} --store-password-in-clear-text ${{ secrets.NUGET_ADDRESS }} && dotnet format --verify-no-changes --diagnostics -v diag --severity warn
dotnetTestArguments: --logger trx --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
dotnetBuildArguments: -c Release
sonarBeginArguments: /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml" -d:sonar.cs.vstest.reportsPaths="**/TestResults/*.trx"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@ -1,9 +1,9 @@
name: Build and Deploy Docker Container
name: Build and Deploy Docker Container
on:
push:
branches:
[master, 'release/*']
[master]
jobs:
build-and-deploy:
@ -24,7 +24,7 @@ jobs:
- name: Build and push Docker image
run: |
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} --build-arg NUGET_ADDRESS=${{ secrets.NUGET_ADDRESS }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
- name: Start ssh-agent
@ -52,6 +52,12 @@ jobs:
SECURITY_HASH_SIZE: ${{ secrets.SECURITY_HASH_SIZE }}
SECURITY_HASH_TOKEN: ${{ secrets.SECURITY_HASH_TOKEN }}
SECURITY_SALT_SIZE: ${{ secrets.SECURITY_SALT_SIZE }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
YANDEX_CLIENT_ID: ${{ secrets.YANDEX_CLIENT_ID }}
YANDEX_CLIENT_SECRET: ${{ secrets.YANDEX_CLIENT_SECRET }}
MAILRU_CLIENT_ID: ${{ secrets.MAILRU_CLIENT_ID }}
MAILRU_CLIENT_SECRET: ${{ secrets.MAILRU_CLIENT_SECRET }}
run: |
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
ssh $SSH_USER@$SSH_HOST "
@ -78,6 +84,12 @@ jobs:
-e ACTUAL_SUB_PATH=api \
-e SWAGGER_SUB_PATH=swagger \
-e TZ=Europe/Moscow \
-e GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \
-e GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \
-e YANDEX_CLIENT_ID=$YANDEX_CLIENT_ID \
-e YANDEX_CLIENT_SECRET=$YANDEX_CLIENT_SECRET \
-e MAILRU_CLIENT_ID=$MAILRU_CLIENT_ID \
-e MAILRU_CLIENT_SECRET=$MAILRU_CLIENT_SECRET \
$DOCKER_IMAGE
"

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of caching mechanisms available.
/// </summary>
public enum CacheType
{
/// <summary>
/// Memcached caching type.
/// </summary>
Memcached,
/// <summary>
/// Redis caching type.
/// </summary>
Redis
}

View File

@ -0,0 +1,24 @@
using System;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a date or date range to skip during cron update scheduling.
/// </summary>
public class CronUpdateSkip
{
/// <summary>
/// Gets or sets the start date of the skip range.
/// </summary>
public DateOnly? Start { get; set; }
/// <summary>
/// Gets or sets the end date of the skip range.
/// </summary>
public DateOnly? End { get; set; }
/// <summary>
/// Gets or sets a specific date to skip.
/// </summary>
public DateOnly? Date { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of databases supported.
/// </summary>
public enum DatabaseType
{
/// <summary>
/// MySQL database type.
/// </summary>
Mysql,
/// <summary>
/// SQLite database type.
/// </summary>
Sqlite,
/// <summary>
/// PostgreSQL database type.
/// </summary>
PostgresSql
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Defines the actions that can be performed with an OAuth token.
/// </summary>
public enum OAuthAction
{
/// <summary>
/// The action to log in the user using the provided OAuth token.
/// </summary>
Login,
/// <summary>
/// The action to bind an OAuth provider to the user's account.
/// </summary>
Bind
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents different OAuth providers for authentication.
/// </summary>
public enum OAuthProvider
{
/// <summary>
/// OAuth provider for Google.
/// </summary>
Google,
/// <summary>
/// OAuth provider for Yandex.
/// </summary>
Yandex,
/// <summary>
/// OAuth provider for Mail.ru.
/// </summary>
MailRu
}

View File

@ -0,0 +1,32 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the password policy settings for user authentication.
/// </summary>
public class PasswordPolicy
{
/// <summary>
/// Gets or sets the minimum length required for a password.
/// </summary>
public int MinimumLength { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one letter is required in the password.
/// </summary>
public bool RequireLetter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the password must contain both lowercase and uppercase letters.
/// </summary>
public bool RequireLettersDifferentCase { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one digit is required in the password.
/// </summary>
public bool RequireDigit { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one special character is required in the password.
/// </summary>
public bool RequireSpecialCharacter { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the steps required after a login attempt.
/// </summary>
public enum TwoFactorAuthentication
{
/// <summary>
/// No additional steps required; the user is successfully logged in.
/// </summary>
None,
/// <summary>
/// TOTP (Time-based One-Time Password) is required for additional verification.
/// </summary>
TotpRequired,
}

View File

@ -22,4 +22,17 @@ public class LoggingRequest
/// Gets or sets the log file path.
/// </summary>
public string? LogFilePath { get; set; }
/// <summary>
/// Gets or sets the API key for integrating with Seq, a log aggregation service.
/// If provided, logs will be sent to a Seq server using this API key.
/// </summary>
public string? ApiKeySeq { get; set; }
/// <summary>
/// Gets or sets the server URL for the Seq logging service.
/// This property specifies the Seq server endpoint to which logs will be sent.
/// If <see cref="ApiKeySeq"/> is provided, logs will be sent to this server.
/// </summary>
public string? ApiServerSeq { get; set; }
}

View File

@ -10,27 +10,21 @@ public class CreateUserRequest
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
/// <remarks>
/// The email address is a required field.
/// </remarks>
[Required]
[EmailAddress]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
/// <remarks>
/// The username is a required field.
/// </remarks>
[Required]
[MinLength(2)]
public required string Username { get; set; }
/// <summary>
/// Gets or sets the password of the user.
/// </summary>
/// <remarks>
/// The password is a required field.
/// </remarks>
[Required]
[MinLength(2)]
public required string Password { get; set; }
}
}

View File

@ -8,30 +8,30 @@ public class ScheduleRequest
/// <summary>
/// Gets or sets an array of group IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Groups { get; set; } = null;
/// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary>
/// <remarks>This property can contain null.</remarks>
public bool? IsEven { get; set; } = null;
/// <summary>
/// Gets or sets an array of discipline IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Disciplines { get; set; } = null;
/// <summary>
/// Gets or sets an array of professor IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Professors { get; set; } = null;
/// <summary>
/// Gets or sets an array of lecture hall IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? LectureHalls { get; set; } = null;
}
/// <summary>
/// Gets or sets an array of lesson type IDs.
/// </summary>
public int[]? LessonType { get; set; } = null;
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Represents a request for verifying two-factor authentication.
/// </summary>
public class TwoFactorAuthRequest
{
/// <summary>
/// Gets or sets the two-factor authentication code provided by the user.
/// </summary>
public required string Code { get; set; }
/// <summary>
/// Gets or sets the type of the two-factor authentication method used (e.g., TOTP, Email).
/// </summary>
public TwoFactorAuthentication Method { get; set; }
}

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Common;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing information about available OAuth providers.
/// </summary>
public class AvailableOAuthProvidersResponse
{
/// <summary>
/// Gets or sets the name of the OAuth provider.
/// </summary>
[Required]
public required string ProviderName { get; set; }
/// <summary>
/// Gets or sets the enum value representing the OAuth provider.
/// </summary>
public OAuthProvider Provider { get; set; }
/// <summary>
/// Gets or sets the redirect URL for the OAuth provider's authorization process.
/// </summary>
[Required]
public required string Redirect { get; set; }
}

View File

@ -28,4 +28,4 @@ public class CampusDetailsResponse
/// Gets or sets the address of the campus (optional).
/// </summary>
public string? Address { get; set; }
}
}

View File

@ -0,0 +1,29 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing cache configuration details.
/// </summary>
public class CacheResponse
{
/// <summary>
/// Gets or sets the type of cache database.
/// </summary>
public CacheType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents the response containing the cron update schedule and the next scheduled task dates.
/// </summary>
public class CronUpdateScheduleResponse
{
/// <summary>
/// Gets or sets the cron expression representing the update schedule.
/// </summary>
[Required]
public required string Cron { get; set; }
/// <summary>
/// Gets or sets the list of next scheduled task dates based on the cron expression.
/// </summary>
[Required]
public required List<DateTime> NextStart { get; set; }
}

View File

@ -0,0 +1,49 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing database configuration details.
/// </summary>
public class DatabaseResponse
{
/// <summary>
/// Gets or sets the type of database.
/// </summary>
public DatabaseType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the database name.
/// </summary>
public string? Database { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
public string? User { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the path to database. Only for Sqlite
/// </summary>
public string? PathToDatabase { get; set; }
}

View File

@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// A class for providing information about an error
/// </summary>
public class ErrorResponse
{
/// <summary>
/// The text or translation code of the error. This field may not contain information in specific scenarios.
/// For example, it might be empty for HTTP 204 responses where no content is returned or if the validation texts have not been configured.
/// </summary>
[Required]
public required string Error { get; set; }
/// <summary>
/// In addition to returning the response code in the header, it is also duplicated in this field.
/// Represents the HTTP response code.
/// </summary>
[Required]
public required int Code { get; set; }
}

View File

@ -3,7 +3,7 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a faculty.
/// Represents information about a faculty.
/// </summary>
public class FacultyResponse
{

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a lesson type.
/// </summary>
public class LessonTypeResponse
{
/// <summary>
/// Gets or sets the unique identifier of the lesson type.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lesson type.
/// </summary>
[Required]
public required string Name { get; set; }
}

View File

@ -114,4 +114,4 @@ public class ScheduleResponse
/// Gets or sets the links to online meetings for the schedule entry.
/// </summary>
public required IEnumerable<string?> LinkToMeet { get; set; }
}
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing the TOTP (Time-Based One-Time Password) key details.
/// </summary>
public class TotpKeyResponse
{
/// <summary>
/// Gets or sets the secret key used for TOTP generation.
/// </summary>
public required string Secret { get; set; }
/// <summary>
/// Gets or sets the image (QR code) representing the TOTP key.
/// </summary>
public required string Image { get; set; }
}

View File

@ -0,0 +1,35 @@
using Mirea.Api.Dto.Common;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents a response containing user information.
/// </summary>
public class UserResponse
{
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
[Required]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has two-factor authentication enabled.
/// </summary>
[Required]
public bool TwoFactorAuthenticatorEnabled { get; set; }
/// <summary>
/// Gets or sets a collection of OAuth providers used by the user.
/// </summary>
[Required]
public required IEnumerable<OAuthProvider> UsedOAuthProviders { get; set; }
}

View File

@ -8,14 +8,15 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore
.editorconfig = .editorconfig
.env = .env
.gitattributes = .gitattributes
.gitignore = .gitignore
.gitea\workflows\deploy-stage.yaml = .gitea\workflows\deploy-stage.yaml
.github\workflows\code-analyze.yaml = .github\workflows\code-analyze.yaml
Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt
README.md = README.md
.gitea\workflows\test.yaml = .gitea\workflows\test.yaml
.github\workflows\release-version.yml = .github\workflows\release-version.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app
@ -13,10 +13,14 @@ COPY . .
ARG NUGET_USERNAME
ARG NUGET_PASSWORD
ARG NUGET_ADDRESS
ENV NUGET_USERNAME=$NUGET_USERNAME
ENV NUGET_PASSWORD=$NUGET_PASSWORD
ENV NUGET_ADDRESS=$NUGET_ADDRESS
RUN dotnet restore ./Backend.sln --configfile nuget.config
RUN dotnet nuget add source --name="Winsomnia" --username ${NUGET_USERNAME} --store-password-in-clear-text --password ${NUGET_PASSWORD} ${NUGET_ADDRESS}
RUN dotnet restore ./Backend.sln
WORKDIR /app
WORKDIR /src
RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app

View File

@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.Dto.Responses;
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
public class BadRequestResponseAttribute() : ProducesResponseTypeAttribute(typeof(ErrorResponse), StatusCodes.Status400BadRequest);
public class BadRequestResponseAttribute() : ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status400BadRequest);

View File

@ -2,7 +2,7 @@
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)]
public class CacheMaxAgeAttribute : Attribute
{
public int MaxAge { get; }

View File

@ -2,5 +2,5 @@
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
public class MaintenanceModeIgnoreAttribute : Attribute;

View File

@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.Dto.Responses;
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
public class NotFoundResponseAttribute() : ProducesResponseTypeAttribute(typeof(ErrorResponse), StatusCodes.Status404NotFound);
public class NotFoundResponseAttribute() : ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status404NotFound);

View File

@ -13,7 +13,7 @@ public class TokenAuthenticationAttribute : Attribute, IActionFilter
public void OnActionExecuting(ActionExecutingContext context)
{
var setupToken = context.HttpContext.RequestServices.GetRequiredService<ISetupToken>();
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out string? tokenFromCookie))
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out var tokenFromCookie))
{
context.Result = new UnauthorizedResult();
return;

View File

@ -0,0 +1,8 @@
using System;
namespace Mirea.Api.Endpoint.Common.Exceptions;
public class ServerUnavailableException(string message, bool addRetryAfter) : Exception(message)
{
public bool AddRetryAfter { get; } = addRetryAfter;
}

View File

@ -3,6 +3,5 @@
public interface IMaintenanceModeNotConfigureService
{
bool IsMaintenanceMode { get; }
void DisableMaintenanceMode();
}

View File

@ -3,8 +3,6 @@
public interface IMaintenanceModeService
{
bool IsMaintenanceMode { get; }
void EnableMaintenanceMode();
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Responses;
using Mirea.Api.Security.Common.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class AvailableProvidersConverter
{
public static Dto.Common.OAuthProvider ConvertToDto(this OAuthProvider provider) =>
provider switch
{
OAuthProvider.Google => Dto.Common.OAuthProvider.Google,
OAuthProvider.Yandex => Dto.Common.OAuthProvider.Yandex,
OAuthProvider.MailRu => Dto.Common.OAuthProvider.MailRu,
_ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null)
};
public static List<AvailableOAuthProvidersResponse> ConvertToDto(this IEnumerable<(OAuthProvider Provider, Uri Redirect)> data) =>
data.Select(x => new AvailableOAuthProvidersResponse()
{
ProviderName = Enum.GetName(x.Provider)!,
Provider = x.Provider.ConvertToDto(),
Redirect = x.Redirect.ToString()
}).ToList();
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class CronUpdateSkipConverter
{
public static List<Dto.Common.CronUpdateSkip> ConvertToDto(this IEnumerable<ScheduleSettings.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => new Dto.Common.CronUpdateSkip()
{
Start = x.Start,
End = x.End,
Date = x.Date
}).ToList();
public static List<ScheduleSettings.CronUpdateSkip> ConvertFromDto(this IEnumerable<Dto.Common.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => x.Get()).ToList();
}

View File

@ -2,12 +2,13 @@
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PairPeriodTimeConverter
{
public static Dictionary<int, Dto.Common.PairPeriodTime> ConvertToDto(this IDictionary<int, ScheduleSettings.PairPeriodTime> pairPeriod) =>
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new Dto.Common.PairPeriodTime { Start = kvp.Value.Start, End = kvp.Value.End });
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) => pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
}
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) =>
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
}

View File

@ -0,0 +1,23 @@
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PasswordPolicyConverter
{
public static Security.Common.Model.PasswordPolicy ConvertFromDto(this PasswordPolicy policy) =>
new(policy.MinimumLength,
policy.RequireLetter,
policy.RequireLettersDifferentCase,
policy.RequireDigit,
policy.RequireSpecialCharacter);
public static PasswordPolicy ConvertToDto(this Security.Common.Model.PasswordPolicy policy) =>
new()
{
MinimumLength = policy.MinimumLength,
RequireLetter = policy.RequireLetter,
RequireDigit = policy.RequireDigit,
RequireSpecialCharacter = policy.RequireSpecialCharacter,
RequireLettersDifferentCase = policy.RequireLettersDifferentCase
};
}

View File

@ -0,0 +1,23 @@
using Mirea.Api.Dto.Common;
using System;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class TwoFactorAuthenticationConverter
{
public static TwoFactorAuthentication ConvertToDto(this Security.Common.Model.TwoFactorAuthenticator authenticator) =>
authenticator switch
{
Security.Common.Model.TwoFactorAuthenticator.None => TwoFactorAuthentication.None,
Security.Common.Model.TwoFactorAuthenticator.Totp => TwoFactorAuthentication.TotpRequired,
_ => throw new ArgumentOutOfRangeException(nameof(authenticator), authenticator, null)
};
public static Security.Common.Model.TwoFactorAuthenticator ConvertFromDto(this TwoFactorAuthentication authentication) =>
authentication switch
{
TwoFactorAuthentication.None => Security.Common.Model.TwoFactorAuthenticator.None,
TwoFactorAuthentication.TotpRequired => Security.Common.Model.TwoFactorAuthenticator.Totp,
_ => throw new ArgumentOutOfRangeException(nameof(authentication), authentication, null)
};
}

View File

@ -0,0 +1,20 @@
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Model;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class UserConverter
{
public static User ConvertToSecurity(this Admin data) =>
new()
{
Id = 1.ToString(),
Email = data.Email,
Username = data.Username,
PasswordHash = data.PasswordHash,
Salt = data.Salt,
SecondFactorToken = data.Secret,
TwoFactorAuthenticator = data.TwoFactorAuthenticator,
OAuthProviders = data.OAuthProviders
};
}

View File

@ -0,0 +1,78 @@
using Cronos;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class CronUpdateSkipService
{
public static ScheduleSettings.CronUpdateSkip Get(this Dto.Common.CronUpdateSkip date)
{
if (date.Date.HasValue)
return new ScheduleSettings.CronUpdateSkip(date.Date.Value);
if (date is { Start: not null, End: not null })
return new ScheduleSettings.CronUpdateSkip(date.Start.Value, date.End.Value);
throw new ArgumentException("It is impossible to create a structure because it has incorrect values.");
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.OrderBy(x => x.End ?? x.Date)
.Where(x => x.Date == currentDate || (x.Start <= currentDate && x.End >= currentDate))
.ToList();
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateTime? currentDate = null) =>
data.FilterDateEntry(DateOnly.FromDateTime(currentDate ?? DateTime.Now));
public static List<ScheduleSettings.CronUpdateSkip> Filter(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.Where(x => x.Date >= currentDate || x.End >= currentDate)
.OrderBy(x => x.End ?? x.Date)
.ToList();
}
public static List<DateTimeOffset> GetNextTask(this List<ScheduleSettings.CronUpdateSkip> data,
CronExpression expression, int depth = 1, DateOnly? currentDate = null)
{
if (depth <= 0)
return [];
DateTimeOffset nextRunTime = (currentDate?.ToDateTime(TimeOnly.MinValue) ?? DateTime.Now);
List<DateTimeOffset> result = [];
do
{
var lastSkippedEntry = data.FilterDateEntry(nextRunTime.DateTime).LastOrDefault();
if (lastSkippedEntry is { Start: not null, End: not null })
nextRunTime = lastSkippedEntry.End.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
else if (lastSkippedEntry.Date.HasValue)
nextRunTime = lastSkippedEntry.Date.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
var nextOccurrence = expression.GetNextOccurrence(nextRunTime.AddMinutes(-1), TimeZoneInfo.Local);
if (!nextOccurrence.HasValue)
return result;
if (data.FilterDateEntry(nextOccurrence.Value.DateTime).Count != 0)
{
nextRunTime = nextOccurrence.Value.AddDays(1);
continue;
}
result.Add(nextOccurrence.Value);
nextRunTime = nextOccurrence.Value.AddMinutes(1);
} while (result.Count < depth);
return result;
}
}

View File

@ -4,12 +4,7 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class ScheduleSyncManager
{
public static event Action? OnUpdateIntervalRequested;
public static event Action? OnForceSyncRequested;
public static void RequestIntervalUpdate() =>
OnUpdateIntervalRequested?.Invoke();
public static void RequestForceSync() =>
OnForceSyncRequested?.Invoke();
}

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
public class DistributedCacheService(IDistributedCache cache) : ICacheService
{
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
CancellationToken cancellationToken = default)
{
var options = new DistributedCacheEntryOptions
{
@ -17,12 +18,43 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
await cache.SetStringAsync(key, value?.ToString() ?? string.Empty, options, cancellationToken);
return;
}
var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value);
await cache.SetAsync(key, serializedValue, options, cancellationToken);
}
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
var primitiveValue = await cache.GetStringAsync(key, cancellationToken);
if (string.IsNullOrEmpty(primitiveValue))
return default;
if (type == typeof(string))
return (T?)(object?)primitiveValue;
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
if (success)
return (T)parameters[1]!;
return default;
}
var cachedValue = await cache.GetAsync(key, cancellationToken);
return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue);
}

View File

@ -33,7 +33,7 @@ public class JwtTokenService : IAccessToken
SigningCredentials = signingCredentials,
Subject = new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, userId),
new Claim(ClaimTypes.NameIdentifier, userId),
// todo: get role by userId
new Claim(ClaimTypes.Role, "")
]),

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
public class MemoryCacheService(IMemoryCache cache) : ICacheService
{
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
CancellationToken cancellationToken = default)
{
var options = new MemoryCacheEntryOptions
{
@ -17,17 +18,41 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
cache.Set(key, value?.ToString() ?? string.Empty, options);
return Task.CompletedTask;
}
cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options);
return Task.CompletedTask;
}
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return Task.FromResult(
cache.TryGetValue(key, out byte[]? value) ?
JsonSerializer.Deserialize<T>(value) :
default
);
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (!type.IsPrimitive && type != typeof(string) && type != typeof(DateTime))
return Task.FromResult(
cache.TryGetValue(key, out byte[]? value) ? JsonSerializer.Deserialize<T>(value) : default
);
var primitiveValue = cache.Get(key);
if (string.IsNullOrEmpty(primitiveValue?.ToString()))
return Task.FromResult<T?>(default);
if (type == typeof(string))
return Task.FromResult((T?)primitiveValue);
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
return success ? Task.FromResult((T?)parameters[1]) : Task.FromResult<T?>(default);
}
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)

View File

@ -6,8 +6,14 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class UrlHelper
{
public static string GetCurrentScheme(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? context.Request.Scheme;
public static string GetCurrentDomain(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? context.Request.Host.Host;
public static int? GetCurrentPort(this HttpContext context) =>
string.IsNullOrEmpty(context.Request.Headers["X-Forwarded-Port"].FirstOrDefault()) ? context.Request.Host.Port :
int.Parse(context.Request.Headers["X-Forwarded-Port"].First()!);
private static string CreateSubPath(string? path)
{
@ -30,17 +36,23 @@ public static class UrlHelper
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < parts.Length; i++)
{
if (!parts[i].Equals("api", StringComparison.CurrentCultureIgnoreCase)) continue;
parts = parts.Take(i).Concat(parts.Skip(i + 1)).ToArray();
break;
}
if (parts[^1].Equals("api", StringComparison.CurrentCultureIgnoreCase))
parts = parts.Take(parts.Length - 1).ToArray();
return CreateSubPath(string.Join("/", parts));
}
}
public static string GetSubPathSwagger => CreateSubPath(Environment.GetEnvironmentVariable("SWAGGER_SUB_PATH"));
public static string GetApiUrl(this HttpContext context, string apiPath = "")
{
var scheme = GetCurrentScheme(context);
var domain = GetCurrentDomain(context).TrimEnd('/').Replace("localhost", "127.0.0.1");
var port = GetCurrentPort(context);
var portString = port.HasValue && port != 80 && port != 443 ? $":{port}" : string.Empty;
return $"{scheme}://{domain}{portString}{GetSubPathWithoutFirstApiName}{apiPath.Trim('/')}";
}
}

View File

@ -5,8 +5,11 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -15,24 +18,47 @@ namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable
{
private Timer? _timer;
private readonly IOptionsMonitor<GeneralConfig> _generalConfigMonitor;
private string _cronUpdate;
private List<ScheduleSettings.CronUpdateSkip> _cronUpdateSkip;
private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider;
private readonly IDisposable? _onChangeUpdateCron;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{
_generalConfigMonitor = generalConfigMonitor;
_logger = logger;
_serviceProvider = serviceProvider;
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested += OnUpdateIntervalRequested;
_onChangeUpdateCron = generalConfigMonitor.OnChange((config) =>
{
var updated = false;
if (config.ScheduleSettings?.CronUpdateSchedule != null && _cronUpdate != config.ScheduleSettings.CronUpdateSchedule)
{
_cronUpdate = config.ScheduleSettings.CronUpdateSchedule;
updated = true;
}
if (config.ScheduleSettings?.CronUpdateSkipDateList != null && !config.ScheduleSettings.CronUpdateSkipDateList.SequenceEqual(_cronUpdateSkip))
{
_cronUpdateSkip = config.ScheduleSettings.CronUpdateSkipDateList
.OrderBy(x => x.End ?? x.Date)
.ToList();
updated = true;
}
if (updated)
OnUpdateIntervalRequested();
});
}
private void OnForceSyncRequested()
{
StopAsync(default).ContinueWith(_ =>
_logger.LogInformation("It was requested to synchronize the data immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
_cancellationTokenSource = new CancellationTokenSource();
ExecuteTask(null);
@ -41,39 +67,42 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void OnUpdateIntervalRequested()
{
StopAsync(default).ContinueWith(_ =>
_logger.LogInformation("It was requested to update the time interval immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
StartAsync(default);
StartAsync(CancellationToken.None);
});
}
private void ScheduleNextRun()
{
var cronExpression = _generalConfigMonitor.CurrentValue.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(cronExpression))
if (string.IsNullOrEmpty(_cronUpdate))
{
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return;
}
var nextRunTime = CronExpression.Parse(cronExpression).GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
var expression = CronExpression.Parse(_cronUpdate);
if (!nextRunTime.HasValue)
var nextRunTime = _cronUpdateSkip.GetNextTask(expression).FirstOrDefault();
if (nextRunTime == default)
{
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}", TimeZoneInfo.Local.DisplayName);
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}",
TimeZoneInfo.Local.DisplayName);
return;
}
_logger.LogInformation("Next task run in {Time}", nextRunTime.Value.ToString("G"));
_logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
var delay = (nextRunTime.Value - DateTimeOffset.Now).TotalMilliseconds;
var delay = (nextRunTime - DateTimeOffset.Now).TotalMilliseconds;
// The chance is small, but it's better to check
if (delay <= 0)
delay = 1;
_cancellationTokenSource = new CancellationTokenSource();
_timer = new Timer(ExecuteTask, null, (int)delay, Timeout.Infinite);
_timer = new Timer(ExecuteTask, null, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
}
private async void ExecuteTask(object? state)
@ -109,10 +138,10 @@ public class ScheduleSyncService : IHostedService, IDisposable
public void Dispose()
{
StopAsync(default).GetAwaiter().GetResult();
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
_timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
_onChangeUpdateCron?.Dispose();
_cancellationTokenSource.Dispose();
GC.SuppressFinalize(this);

View File

@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Common;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class CookieAuthorizationMiddleware(RequestDelegate next)
{
public const string JwtAuthorizationName = "_ajwt";
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Cookies.ContainsKey(JwtAuthorizationName))
context.Request.Headers.Authorization = "Bearer " + context.Request.Cookies[JwtAuthorizationName];
if (context.Request.Cookies.ContainsKey(CookieNames.AccessToken))
context.Request.Headers.Authorization = "Bearer " + context.Request.Cookies[CookieNames.AccessToken];
await next(context);
}

View File

@ -1,10 +1,14 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Mirea.Api.DataAccess.Application.Common.Exceptions;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Exceptions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security;
using System.Text.Json;
using System.Threading.Tasks;
@ -26,47 +30,65 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var code = StatusCodes.Status500InternalServerError;
var result = string.Empty;
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
var problemDetails = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError,
Detail = "Please provide this traceId to the administrator for further investigation.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", traceId }
}
};
switch (exception)
{
case ValidationException validationException:
code = StatusCodes.Status400BadRequest;
result = JsonSerializer.Serialize(new ErrorResponse()
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Validation errors occurred.";
problemDetails.Extensions = new Dictionary<string, object?>
{
Error = validationException.Message,
Code = code
});
{ "errors", validationException.Errors.Select(e => e.ErrorMessage).ToArray() },
{ "traceId", traceId }
};
break;
case NotFoundException:
code = StatusCodes.Status404NotFound;
problemDetails.Status = StatusCodes.Status404NotFound;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4";
problemDetails.Title = "Resource not found.";
break;
case ControllerArgumentException:
code = StatusCodes.Status400BadRequest;
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Invalid arguments provided.";
problemDetails.Detail = exception.Message;
break;
case SecurityException:
problemDetails.Status = StatusCodes.Status401Unauthorized;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
problemDetails.Title = "Unauthorized access.";
problemDetails.Detail = exception.Message;
break;
case ServerUnavailableException unavailableException:
problemDetails.Status = StatusCodes.Status503ServiceUnavailable;
problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.6.4";
problemDetails.Title = "Server unavailable.";
problemDetails.Detail = unavailableException.Message;
if (unavailableException.AddRetryAfter)
context.Response.Headers.RetryAfter = "600";
break;
}
context.Response.ContentType = "application/json";
context.Response.StatusCode = code;
if (!string.IsNullOrEmpty(result))
return context.Response.WriteAsync(result);
string error;
if (code == StatusCodes.Status500InternalServerError)
{
error = "Internal Server Error";
if (problemDetails.Status == StatusCodes.Status500InternalServerError)
logger.LogError(exception, "Internal server error when processing the request");
}
else
error = exception.Message;
result = JsonSerializer.Serialize(new ErrorResponse()
{
Error = error,
Code = code
});
context.Response.ContentType = "application/json";
context.Response.StatusCode = problemDetails.Status.Value;
return context.Response.WriteAsync(result);
return context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails));
}
}

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Interfaces;
using System.Threading.Tasks;
@ -23,17 +24,11 @@ public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeSer
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "plain/text";
string error;
if (maintenanceModeService.IsMaintenanceMode)
{
context.Response.Headers.RetryAfter = "600";
error = "The service is currently undergoing maintenance. Please try again later.";
}
else
error =
"The service is currently not configured. Go to the setup page if you are an administrator or try again later.";
throw new ServerUnavailableException("The service is currently undergoing maintenance. Please try again later.", true);
await context.Response.WriteAsync(error);
throw new ServerUnavailableException(
"The service is currently not configured. Go to the setup page if you are an administrator or try again later.", false);
}
}
}

View File

@ -1,7 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
@ -10,7 +10,7 @@ public static class CacheConfiguration
public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthChecksBuilder = null)
{
var cache = configuration.Get<GeneralConfig>()?.CacheSettings;
if (cache?.TypeDatabase != CacheSettings.CacheEnum.Redis)
if (cache?.TypeDatabase != CacheType.Redis)
return services;
services.AddStackExchangeRedisCache(options =>

View File

@ -21,7 +21,7 @@ public static class EnvironmentConfiguration
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
string arg = line;
var arg = line;
if (commentIndex != -1)
arg = arg.Remove(commentIndex, arg.Length - commentIndex);

View File

@ -19,12 +19,14 @@ public static class JwtConfiguration
var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty);
if (jwtDecrypt.Length != 32)
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. Now the size is equal is " + jwtDecrypt.Length);
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. " +
"Now the size is equal is " + jwtDecrypt.Length);
var jwtKey = Encoding.UTF8.GetBytes(configuration["SECURITY_SIGNING_TOKEN"] ?? string.Empty);
if (jwtKey.Length != 64)
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. Now the size is " + jwtKey.Length);
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. " +
"Now the size is " + jwtKey.Length);
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
@ -62,4 +64,4 @@ public static class JwtConfiguration
};
});
}
}
}

View File

@ -4,9 +4,12 @@ using Microsoft.Extensions.Hosting;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using Serilog.Filters;
using Serilog.Formatting.Compact;
using System;
using System.Diagnostics;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
@ -43,10 +46,16 @@ public static class LoggerConfiguration
rollingInterval: RollingInterval.Day);
}
if (generalConfig != null && !string.IsNullOrEmpty(generalConfig.ApiServerSeq) &&
Uri.TryCreate(generalConfig.ApiServerSeq, UriKind.Absolute, out var _))
configuration.WriteTo.Seq(generalConfig.ApiServerSeq, apiKey: generalConfig.ApiKeySeq);
configuration
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning);
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authorization", LogEventLevel.Warning);
configuration.Filter.ByExcluding(Matching.WithProperty<string>("SourceContext", sc =>
sc.Contains("Microsoft.EntityFrameworkCore.Database.Command")));
@ -55,7 +64,17 @@ public static class LoggerConfiguration
public static IApplicationBuilder UseCustomSerilog(this IApplicationBuilder app)
{
return app.UseSerilogRequestLogging(options =>
return app.Use(async (context, next) =>
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
using (LogContext.PushProperty("TraceId", traceId))
using (LogContext.PushProperty("UserAgent", context.Request.Headers.UserAgent.ToString()))
using (LogContext.PushProperty("RemoteIPAddress", context.Connection.RemoteIpAddress?.ToString()))
{
await next();
}
}).UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "[{RequestMethod}] {RequestPath} [Client {RemoteIPAddress}] [{StatusCode}] in {Elapsed:0.0000} ms";
@ -76,8 +95,8 @@ public static class LoggerConfiguration
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString());
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
};
});
}
}
}

View File

@ -1,8 +1,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Common.Services.Security;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security;
using Mirea.Api.Security.Common.Interfaces;
@ -16,7 +16,7 @@ public static class SecureConfiguration
services.AddSingleton<IRevokedToken, MemoryRevokedTokenService>();
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheSettings.CacheEnum.Redis)
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheType.Redis)
services.AddSingleton<ICacheService, DistributedCacheService>();
else
services.AddSingleton<ICacheService, MemoryCacheService>();

View File

@ -17,8 +17,11 @@ public static class SwaggerConfiguration
{
services.AddSwaggerGen(options =>
{
options.SchemaFilter<SwaggerExampleFilter>();
options.OperationFilter<SwaggerDefaultValues>();
options.OperationFilter<TagSchemeFilter>();
options.SchemaFilter<ExampleFilter>();
options.OperationFilter<DefaultValues>();
options.OperationFilter<ActionResultSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme

View File

@ -2,4 +2,4 @@
public interface ISaveSettings
{
void SaveSetting();
}
}

View File

@ -1,4 +1,7 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -8,15 +11,28 @@ namespace Mirea.Api.Endpoint.Configuration.Model;
public class Admin : ISaveSettings
{
[JsonIgnore] private const string FileName = "admin.json";
private string _username = string.Empty;
private string _email = string.Empty;
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public required string Username { get; set; }
public required string Email { get; set; }
public required string Username
{
get => _username;
set => _username = value.Trim();
}
public required string Email
{
get => _email;
set => _email = value.Trim();
}
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
public string? Secret { get; set; }
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
public void SaveSetting()
{

View File

@ -1,5 +1,6 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security.Common.Model;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -18,6 +19,8 @@ public class GeneralConfig : ISaveSettings
public ScheduleSettings? ScheduleSettings { get; set; }
public EmailSettings? EmailSettings { get; set; }
public LogSettings? LogSettings { get; set; }
public PasswordPolicy PasswordPolicy { get; set; } = new();
public string? SecretForwardToken { get; set; }
public void SaveSetting()

View File

@ -1,4 +1,5 @@
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
@ -6,18 +7,12 @@ namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class CacheSettings : IIsConfigured
{
public enum CacheEnum
{
Memcached,
Redis
}
public CacheEnum TypeDatabase { get; set; }
public CacheType TypeDatabase { get; set; }
public string? ConnectionString { get; set; }
public bool IsConfigured()
{
return TypeDatabase == CacheEnum.Memcached ||
return TypeDatabase == CacheType.Memcached ||
!string.IsNullOrEmpty(ConnectionString);
}
}

View File

@ -1,4 +1,5 @@
using Mirea.Api.DataAccess.Persistence.Common;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
using System;
@ -9,22 +10,16 @@ namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class DbSettings : IIsConfigured
{
public enum DatabaseEnum
{
Mysql,
Sqlite,
PostgresSql
}
public DatabaseEnum TypeDatabase { get; set; }
public DatabaseType TypeDatabase { get; set; }
public required string ConnectionStringSql { get; set; }
[JsonIgnore]
public DatabaseProvider DatabaseProvider =>
TypeDatabase switch
{
DatabaseEnum.PostgresSql => DatabaseProvider.Postgresql,
DatabaseEnum.Mysql => DatabaseProvider.Mysql,
DatabaseEnum.Sqlite => DatabaseProvider.Sqlite,
DatabaseType.PostgresSql => DatabaseProvider.Postgresql,
DatabaseType.Mysql => DatabaseProvider.Mysql,
DatabaseType.Sqlite => DatabaseProvider.Sqlite,
_ => throw new ArgumentOutOfRangeException()
};

View File

@ -9,6 +9,8 @@ public class LogSettings : IIsConfigured
public bool EnableLogToFile { get; set; }
public string? LogFilePath { get; set; }
public string? LogFileName { get; set; }
public string? ApiKeySeq { get; set; }
public string? ApiServerSeq { get; set; }
public bool IsConfigured()
{

View File

@ -31,9 +31,33 @@ public class ScheduleSettings : IIsConfigured
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { }
}
public record struct CronUpdateSkip
{
public DateOnly? Start { get; set; }
public DateOnly? End { get; set; }
public DateOnly? Date { get; set; }
public CronUpdateSkip(DateOnly d1, DateOnly d2)
{
if (d1 > d2)
{
Start = d2;
End = d1;
}
else
{
Start = d1;
End = d2;
}
}
public CronUpdateSkip(DateOnly d1) => Date = d1;
}
public required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public List<CronUpdateSkip> CronUpdateSkipDateList { get; set; } = [];
public bool IsConfigured()
{

View File

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class ActionResultSchemaFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var returnType = context.MethodInfo.ReturnType;
if (!returnType.IsEquivalentTo(typeof(ActionResult)) &&
!returnType.IsEquivalentTo(typeof(ContentResult)) &&
!returnType.IsEquivalentTo(typeof(FileStreamResult)) &&
!returnType.IsGenericType)
return;
if (returnType.IsGenericType &&
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(ActionResult<>)) &&
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(Task<>)))
return;
var genericType = returnType.IsGenericType ? returnType.GetGenericArguments().FirstOrDefault() : returnType;
if (genericType == null)
return;
var responseTypeAttributes = context.MethodInfo.GetCustomAttributes(typeof(ProducesResponseTypeAttribute), false)
.Cast<ProducesResponseTypeAttribute>()
.Where(attr => attr.StatusCode == 200)
.ToList();
var contentType = "application/json";
if (context.MethodInfo.GetCustomAttributes(typeof(ProducesAttribute), false)
.FirstOrDefault() is ProducesAttribute producesAttribute)
contentType = producesAttribute.ContentTypes.FirstOrDefault() ?? "application/json";
if (responseTypeAttributes.Count != 0)
{
var responseType = responseTypeAttributes.First().Type;
genericType = responseType;
}
if (genericType.IsEquivalentTo(typeof(ContentResult)) || genericType.IsEquivalentTo(typeof(FileStreamResult)))
{
operation.Responses["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
[contentType] = new()
}
};
}
else if (genericType == typeof(ActionResult))
{
operation.Responses["200"] = new OpenApiResponse { Description = "OK" };
}
else
{
OpenApiSchema schema;
if (genericType.IsGenericType && genericType.GetGenericTypeDefinition() == typeof(ActionResult<>))
schema = context.SchemaGenerator.GenerateSchema(genericType.GetGenericArguments().FirstOrDefault(),
context.SchemaRepository);
else
schema = context.SchemaGenerator.GenerateSchema(genericType, context.SchemaRepository);
operation.Responses["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
[contentType] = new() { Schema = schema }
}
};
}
}
}

View File

@ -4,6 +4,8 @@ using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Diagnostics;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
@ -12,18 +14,17 @@ public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) :
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
}
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
{
var info = new OpenApiInfo()
{
Title = "MIREA Schedule Web API",
Title = $"MIREA Schedule Web API ({FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion})",
Version = description.ApiVersion.ToString(),
Description = "This API provides a convenient interface for retrieving data stored in the database. Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
Description = "This API provides a convenient interface for retrieving data stored in the database. " +
"Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
};

View File

@ -8,7 +8,7 @@ using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter
public class DefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
@ -23,16 +23,12 @@ public class SwaggerDefaultValues : IOperationFilter
foreach (var contentType in response.Content.Keys)
{
if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
{
response.Content.Remove(contentType);
}
}
}
if (operation.Parameters == null)
{
return;
}
foreach (var parameter in operation.Parameters)
{

View File

@ -0,0 +1,28 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class EnumSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (!context.Type.IsEnum)
return;
schema.Enum.Clear();
var enumValues = Enum.GetNames(context.Type)
.Select(name => new OpenApiString(name))
.ToList();
foreach (var value in enumValues)
schema.Enum.Add(value);
schema.Type = "string";
schema.Format = null;
}
}

View File

@ -5,7 +5,7 @@ using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter
public class ExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{

View File

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Linq;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class TagSchemeFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return;
var controllerType = controllerActionDescriptor.ControllerTypeInfo;
var tagsAttribute = controllerType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute == null)
{
var baseType = controllerType.BaseType;
while (baseType != null)
{
tagsAttribute = baseType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute != null)
break;
baseType = baseType.BaseType;
}
}
if (tagsAttribute == null)
return;
operation.Tags ??= [];
operation.Tags.Add(new OpenApiTag { Name = tagsAttribute.Tags[0] });
}
}

View File

@ -14,8 +14,8 @@ public class SetupTokenService : ISetupToken
var token2 = Token.Value.Span;
int result = 0;
for (int i = 0; i < Token.Value.Length; i++)
var result = 0;
for (var i = 0; i < Token.Value.Length; i++)
result |= token2[i] ^ token[i];
return result == 0;

View File

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Route("api/v{version:apiVersion}/Configuration/[controller]")]
[Authorize]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Tags("Configuration")]
public class ConfigurationBaseController : BaseController;

View File

@ -4,29 +4,42 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using Mirea.Api.Security.Services;
using MySqlConnector;
using Npgsql;
using Serilog;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography;
using System.Threading.Tasks;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
namespace Mirea.Api.Endpoint.Controllers.SetupConfiguration;
[ApiVersion("1.0")]
[MaintenanceModeIgnore]
@ -35,7 +48,8 @@ public class SetupController(
ISetupToken setupToken,
IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache,
PasswordHashService passwordHashService) : BaseController
PasswordHashService passwordHashService,
OAuthService oAuthService) : BaseController
{
private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin";
@ -69,8 +83,18 @@ public class SetupController(
[HttpGet("CheckToken")]
public ActionResult<bool> CheckToken([FromQuery] string token)
{
if (!setupToken.MatchToken(Convert.FromBase64String(token)))
return Unauthorized("The token is not valid");
byte[] tokenBase64;
try
{
tokenBase64 = Convert.FromBase64String(token);
}
catch (FormatException)
{
throw new ControllerArgumentException("A token of the wrong format.");
}
if (!setupToken.MatchToken(tokenBase64))
throw new SecurityException("The token is not valid");
Response.Cookies.Append(TokenAuthenticationAttribute.AuthToken, token, new CookieOptions
{
@ -84,7 +108,12 @@ public class SetupController(
return Ok(true);
}
private ActionResult<bool> SetDatabase<TConnection, TException>(string connectionString, DbSettings.DatabaseEnum databaseType)
[HttpGet("IsConfiguredToken")]
[TokenAuthentication]
public ActionResult<bool> IsConfiguredToken() =>
Ok(true);
private void SetDatabase<TConnection, TException>(string connectionString, DatabaseType databaseType)
where TConnection : class, IDbConnection, new()
where TException : Exception
{
@ -107,8 +136,6 @@ public class SetupController(
TypeDatabase = databaseType
};
GeneralConfig = general;
return Ok(true);
}
catch (TException ex)
{
@ -121,13 +148,26 @@ public class SetupController(
[BadRequestResponse]
public ActionResult<bool> SetPsql([FromBody] DatabaseRequest request)
{
string connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
var connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
if (request.Password != null)
connectionString += $";Password={request.Password}";
if (request.Ssl)
connectionString += ";SSL Mode=Require;";
return SetDatabase<NpgsqlConnection, NpgsqlException>(connectionString, DbSettings.DatabaseEnum.PostgresSql);
SetDatabase<NpgsqlConnection, NpgsqlException>(connectionString, DatabaseType.PostgresSql);
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.PostgresSql,
Database = request.Database,
Password = request.Password,
Ssl = request.Ssl,
Port = request.Port,
Server = request.Server,
User = request.User
});
return Ok(true);
}
[HttpPost("SetMysql")]
@ -135,13 +175,25 @@ public class SetupController(
[BadRequestResponse]
public ActionResult<bool> SetMysql([FromBody] DatabaseRequest request)
{
string connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
var connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
if (request.Password != null)
connectionString += $"Pwd={request.Password};";
if (request.Ssl)
connectionString += "SslMode=Require;";
return SetDatabase<MySqlConnection, MySqlException>(connectionString, DbSettings.DatabaseEnum.Mysql);
SetDatabase<MySqlConnection, MySqlException>(connectionString, DatabaseType.Mysql);
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.Mysql,
Database = request.Database,
Password = request.Password,
Ssl = request.Ssl,
Port = request.Port,
Server = request.Server,
User = request.User
});
return Ok(true);
}
[HttpPost("SetSqlite")]
@ -168,20 +220,32 @@ public class SetupController(
var filePath = Path.Combine(path, "database.db3");
var connectionString = $"Data Source={filePath}";
var result = SetDatabase<SqliteConnection, SqliteException>(connectionString, DbSettings.DatabaseEnum.Sqlite);
SetDatabase<SqliteConnection, SqliteException>(connectionString, DatabaseType.Sqlite);
foreach (var file in Directory.GetFiles(path))
System.IO.File.Delete(file);
return result;
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.Sqlite,
PathToDatabase = path
});
return Ok(true);
}
[HttpGet("DatabaseConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<DatabaseRequest> DatabaseConfiguration() =>
cache.TryGetValue<DatabaseResponse>("database", out var response) ? Ok(response) : NoContent();
[HttpPost("SetRedis")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetRedis([FromBody] CacheRequest request)
{
string connectionString = $"{request.Server}:{request.Port},ssl=false";
var connectionString = $"{request.Server}:{request.Port},ssl=false";
if (request.Password != null)
connectionString += $",password={request.Password}";
@ -194,10 +258,17 @@ public class SetupController(
general.CacheSettings = new CacheSettings
{
ConnectionString = connectionString,
TypeDatabase = CacheSettings.CacheEnum.Redis
TypeDatabase = CacheType.Redis
};
GeneralConfig = general;
cache.Set("cache", new CacheResponse
{
Type = CacheType.Redis,
Server = request.Server,
Password = request.Password,
Port = request.Port
});
return Ok(true);
}
catch (Exception ex)
@ -215,30 +286,35 @@ public class SetupController(
general.CacheSettings = new CacheSettings
{
ConnectionString = null,
TypeDatabase = CacheSettings.CacheEnum.Memcached
TypeDatabase = CacheType.Memcached
};
GeneralConfig = general;
cache.Set("cache", new CacheResponse
{
Type = CacheType.Memcached
});
return Ok(true);
}
[HttpGet("CacheConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<CacheResponse> CacheConfiguration() =>
cache.TryGetValue<CacheResponse>("cache", out var response) ? Ok(response) : NoContent();
[HttpPost("CreateAdmin")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest user)
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest userRequest)
{
if (!PasswordHashService.HasPasswordInPolicySecurity(user.Password))
throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character.");
if (!MailAddress.TryCreate(user.Email, out _))
throw new ControllerArgumentException("The email address is incorrect.");
var (salt, hash) = passwordHashService.HashPassword(user.Password);
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password);
var (salt, hash) = passwordHashService.HashPassword(userRequest.Password);
var admin = new Admin
{
Username = user.Username,
Email = user.Email,
Username = userRequest.Username,
Email = userRequest.Email,
PasswordHash = hash,
Salt = salt
};
@ -247,6 +323,98 @@ public class SetupController(
return Ok(true);
}
[HttpGet("HandleToken")]
[TokenAuthentication]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
{
var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
{
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
}, HttpContext, token);
if (!isSuccess || user == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
{
admin = new Admin()
{
Email = user.Email ?? string.Empty,
Username = user.Username ?? string.Empty,
PasswordHash = string.Empty,
Salt = string.Empty,
OAuthProviders = new Dictionary<OAuthProvider, OAuthUser>
{
{provider.Value, user}
}
};
cache.Set(CacheAdminKey, admin);
return Ok();
}
if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, user);
cache.Set(CacheAdminKey, admin);
return Ok();
}
[HttpGet("AdminConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<UserResponse> AdminConfiguration() =>
cache.TryGetValue<Admin>(CacheAdminKey, out var admin) ? Ok(new UserResponse()
{
Email = admin!.Email,
Username = admin.Username,
TwoFactorAuthenticatorEnabled = admin.TwoFactorAuthenticator != TwoFactorAuthenticator.None,
UsedOAuthProviders = admin.OAuthProviders == null ? [] : admin.OAuthProviders.Keys.Select(x => x.ConvertToDto())
}) : NoContent();
[HttpGet("GenerateTotpKey")]
[TokenAuthentication]
public ActionResult<string> GenerateTotpKey()
{
if (cache.TryGetValue<string>("totpSecret", out var secret))
return secret!;
secret = GeneratorKey.GenerateAlphaNumericBase32Compatible(16);
cache.Set("totpSecret", secret);
return secret;
}
[HttpGet("VerifyTotp")]
[TokenAuthentication]
public ActionResult<bool> VerifyTotp([FromQuery] string code)
{
var isCorrect = cache.TryGetValue<string>("totpSecret", out var secret) &&
new TotpService(secret!).VerifyToken(code);
if (!isCorrect || !cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
return false;
admin!.Secret = secret;
admin.TwoFactorAuthenticator = TwoFactorAuthenticator.Totp;
cache.Set(CacheAdminKey, admin);
return true;
}
[HttpPost("SetLogging")]
[TokenAuthentication]
[BadRequestResponse]
@ -266,6 +434,29 @@ public class SetupController(
}
};
if (!string.IsNullOrEmpty(request?.ApiServerSeq))
{
settings.ApiServerSeq = request.ApiServerSeq;
settings.ApiKeySeq = request.ApiKeySeq;
try
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq(settings.ApiServerSeq, apiKey: settings.ApiKeySeq)
.CreateLogger();
Log.Warning("Testing configuration Seq.");
}
catch
{
// ignoring
}
finally
{
Log.CloseAndFlush();
}
}
if (settings.EnableLogToFile)
{
if (string.IsNullOrEmpty(settings.LogFileName))
@ -281,9 +472,24 @@ public class SetupController(
general.LogSettings = settings;
GeneralConfig = general;
cache.Set("logging", new LoggingRequest
{
EnableLogToFile = settings.EnableLogToFile,
LogFileName = settings.LogFileName,
LogFilePath = settings.LogFilePath,
ApiKeySeq = settings.ApiKeySeq,
ApiServerSeq = settings.ApiServerSeq
});
return true;
}
[HttpGet("LoggingConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<LoggingRequest> LoggingConfiguration() =>
cache.TryGetValue<LoggingRequest>("logging", out var data) ? Ok(data) : NoContent();
[HttpPost("SetEmail")]
[TokenAuthentication]
[BadRequestResponse]
@ -307,9 +513,16 @@ public class SetupController(
general.EmailSettings = settings;
GeneralConfig = general;
cache.Set("email", settings);
return true;
}
[HttpGet("EmailConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<EmailRequest> EmailConfiguration() =>
cache.TryGetValue<EmailRequest>("email", out var data) ? Ok(data) : NoContent();
[HttpPost("SetSchedule")]
[TokenAuthentication]
[BadRequestResponse]
@ -338,9 +551,35 @@ public class SetupController(
GeneralConfig = general;
cache.Set("schedule", new ScheduleConfigurationRequest()
{
StartTerm = general.ScheduleSettings.StartTerm,
CronUpdateSchedule = general.ScheduleSettings.CronUpdateSchedule
});
return true;
}
[HttpGet("ScheduleConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<ScheduleConfigurationRequest> ScheduleConfiguration() =>
cache.TryGetValue<ScheduleConfigurationRequest>("schedule", out var data) ? Ok(data) : NoContent();
[HttpPost("SetPasswordPolicy")]
[TokenAuthentication]
public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)
{
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Model.PasswordPolicy();
cache.Set("password", true);
return true;
}
[HttpGet("PasswordPolicyConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<PasswordPolicy> PasswordPolicyConfiguration() =>
cache.TryGetValue("password", out _) ? Ok(GeneralConfig.PasswordPolicy) : NoContent();
[HttpPost("Submit")]
[TokenAuthentication]
[BadRequestResponse]

View File

@ -2,111 +2,259 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Core.Middleware;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Dto.Requests;
using Mirea.Api.Security.Services;
using System;
using System.Security;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth,
PasswordHashService passwordService, OAuthService oAuthService) : BaseController
{
private string Fingerprint { get; set; } = string.Empty;
private string Ip { get; set; } = string.Empty;
private string UserAgent { get; set; } = string.Empty;
private string RefreshToken { get; set; } = string.Empty;
private void SetCookie(string name, string value, DateTimeOffset? expires = null)
{
var cookieOptions = new CookieOptions
private CookieOptions GetCookieParams() =>
new()
{
Expires = expires,
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = HttpContext.GetCurrentDomain(),
HttpOnly = true,
#if !DEBUG
Secure = true
#endif
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
};
Response.Cookies.Append(name, value, cookieOptions);
}
private void SetRefreshToken(string value, DateTimeOffset? expires = null)
private static string GenerateHtmlResponse(
string title,
string message,
Uri? callback,
string traceId,
bool isError)
{
SetCookie("refresh_token", value, expires);
SetCookie("user_key", Fingerprint);
var callbackUrl = callback?.ToString();
var script = callback == null ? string.Empty :
$"<script>setTimeout(()=>{{window.location.href='{callbackUrl}';}}, {(isError ? 15000 : 5000)});</script>";
var blockInfo = "<p>" + (callback == null ?
"Вернитесь обратно и попробуйте снова позже.</p>" :
$"Если вы не будете автоматически перенаправлены, нажмите ниже.</p>" +
$"<a href=\"{callbackUrl}\" style=\"color:inherit;text-decoration:underline;\">Перейти вручную</a>");
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>";
}
private void SetFirstToken(string value, DateTimeOffset? expires = null)
{
SetCookie("authentication_token", value, expires);
SetCookie("user_key", Fingerprint);
}
private void SetAuthToken(string value, DateTimeOffset? expires = null)
{
SetCookie(CookieAuthorizationMiddleware.JwtAuthorizationName, value, expires);
SetCookie("user_key", Fingerprint);
}
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuting(ActionExecutingContext context)
{
Ip = HttpContext.Connection.RemoteIpAddress?.ToString()!;
UserAgent = Request.Headers.UserAgent.ToString();
Fingerprint = Request.Cookies["user_key"] ?? string.Empty;
RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(Fingerprint)) return;
Fingerprint = Guid.NewGuid().ToString().Replace("-", "");
}
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuted(ActionExecutedContext context) { }
/// <summary>
/// Handles user authentication by verifying the username/email and password,
/// then generating and returning an authentication token if successful.
/// Handles the callback from an OAuth2 provider and finalizes the authorization process.
/// </summary>
/// <param name="request">The login request containing the username/email and password.</param>
/// <returns>User's AuthRoles.</returns>
/// <remarks>
/// This method processes the response from an OAuth provider after the user authorizes the application.
/// Upon successful authorization, it redirects the user back to the specified callback URL.
/// </remarks>
/// <param name="code">The authorization code returned by the OAuth provider.</param>
/// <param name="state">The state parameter to ensure the request's integrity and prevent CSRF attacks.</param>
/// <returns>
/// An HTML response indicating the success or failure of the authorization process.
/// If a callback URL is provided, the user will be redirected to it.
/// </returns>
[HttpGet("OAuth2")]
[BadRequestResponse]
[Produces("text/html")]
[MaintenanceModeIgnore]
public async Task<ContentResult> OAuth2([FromQuery] string? code, [FromQuery] string? state)
{
var traceId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
return Content(GenerateHtmlResponse(
"Ошибка передачи данных!",
"Провайдер OAuth не передал нужных данных.",
null,
traceId,
true), "text/html");
var result = await oAuthService.LoginOAuth(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
string? callbackUrl = null;
if (result.Callback != null)
callbackUrl = result.Callback + (result.Callback.Query.Length > 0 ? "&" : "?") +
$"result={Uri.EscapeDataString(result.Token)}";
string title, message;
if (!result.Success)
{
if (callbackUrl != null)
callbackUrl += $"&traceId={Uri.EscapeDataString(traceId)}";
title = "Ошибка авторизации!";
message = result.ErrorMessage ?? "Произошла ошибка. Попробуйте ещё раз.";
}
else
{
title = "Авторизация завершена!";
message = "Вы будете перенаправлены обратно через несколько секунд.";
}
return Content(GenerateHtmlResponse(
title,
message,
callbackUrl == null ? null : new Uri(callbackUrl),
traceId,
!result.Success), "text/html");
}
/// <summary>
/// Initiates the OAuth2 authorization process for the selected provider.
/// </summary>
/// <remarks>
/// This method generates a redirect URL for the selected provider and redirects the user to it.
/// </remarks>
/// <param name="provider">The identifier of the OAuth provider to authorize with.</param>
/// <param name="callback">The address where the user will need to be redirected after the end of communication with the OAuth provider</param>
/// <returns>A redirect to the OAuth provider's authorization URL.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the specified provider is not valid.</exception>
[HttpGet("AuthorizeOAuth2")]
[MaintenanceModeIgnore]
public ActionResult AuthorizeOAuth2([FromQuery] int provider, [FromQuery] Uri callback)
{
if (!Enum.IsDefined(typeof(OAuthProvider), provider))
throw new ControllerArgumentException("There is no selected provider");
if (!callback.IsAbsoluteUri)
throw new ControllerArgumentException("The callback URL must be absolute.");
return Redirect(oAuthService.GetProviderRedirect(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!),
(OAuthProvider)provider,
callback).AbsoluteUri);
}
/// <summary>
/// Retrieves a list of available OAuth providers with their corresponding authorization URLs.
/// </summary>
/// <remarks>
/// This allows the client to fetch all possible OAuth options and the URLs required to initiate authorization.
/// </remarks>
/// <returns>A list of available providers and their redirect URLs.</returns>
[HttpGet("AvailableProviders")]
[MaintenanceModeIgnore]
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders([FromQuery] Uri callback) =>
Ok(oAuthService
.GetAvailableProviders(HttpContext.GetApiUrl(Url.Action("AuthorizeOAuth2")!))
.Select(x =>
{
if (!callback.IsAbsoluteUri)
throw new ControllerArgumentException("The callback URL must be absolute.");
x.Redirect = new Uri(x.Redirect + "&callback=" + Uri.EscapeDataString(callback.AbsoluteUri));
return x;
})
.ConvertToDto());
/// <summary>
/// Processes the OAuth token
/// </summary>
/// <param name="token">The OAuth token used for authentication or binding.</param>
/// <param name="action">The action to be performed: Login or Bind.</param>
/// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")]
[BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action)
{
var (oAuthUser, error, isSuccess, provider) = await oAuthService.GetOAuthUser(GetCookieParams(), HttpContext, token);
if (!isSuccess || oAuthUser == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
switch (action)
{
case OAuthAction.Login:
return Ok(await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, user.Value.ConvertToSecurity(), oAuthUser, provider.Value));
case OAuthAction.Bind:
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var admin = user.Value;
if (string.IsNullOrEmpty(userId) || !int.TryParse(userId, out var result) || result != 1)
return Unauthorized(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "The user is not logged in to link accounts.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
if (admin.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, oAuthUser);
admin.SaveSetting();
return Ok();
default:
throw new ControllerArgumentException("The action cannot be processed.");
}
}
/// <summary>
/// Logs in a user using their username or email and password.
/// </summary>
/// <param name="request">The login request containing username/email and password.</param>
/// <returns>A TwoFactorAuthentication token if the login is successful; otherwise, a BadRequest response.</returns>
[HttpPost("Login")]
[BadRequestResponse]
public async Task<ActionResult<AuthRoles>> Login([FromBody] LoginRequest request)
public async Task<ActionResult<TwoFactorAuthentication>> Login([FromBody] LoginRequest request)
{
var userEntity = user.Value;
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) ||
!passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash))
return BadRequest("Invalid username/email or password");
var tokenResult = await auth.LoginAsync(
GetCookieParams(),
HttpContext,
userEntity.ConvertToSecurity(),
request.Password, request.Username);
var token = await auth.GenerateAuthTokensAsync(new TokenRequest
{
Fingerprint = Fingerprint,
Ip = Ip,
UserAgent = UserAgent
}, "1");
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
SetAuthToken(token.AccessToken, token.AccessExpiresIn);
return Ok(AuthRoles.Admin);
return Ok(tokenResult.ConvertToDto());
}
/// <summary>
/// Performs two-factor authentication for the user.
/// </summary>
/// <param name="request">The request containing the method and code for two-factor authentication.</param>
/// <returns>A boolean indicating whether the two-factor authentication was successful.</returns>
[HttpPost("2FA")]
[BadRequestResponse]
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request) =>
await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
/// <summary>
/// Refreshes the authentication token using the existing refresh token.
/// </summary>
@ -116,30 +264,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthRoles>> ReLogin()
{
if (string.IsNullOrEmpty(RefreshToken))
return Unauthorized();
try
{
var token = await auth.RefreshTokenAsync(
new TokenRequest
{
Ip = Ip,
UserAgent = UserAgent,
Fingerprint = Fingerprint
},
RefreshToken
);
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
SetAuthToken(token.AccessToken, token.AccessExpiresIn);
return Ok(AuthRoles.Admin);
}
catch (SecurityException)
{
return Forbid();
}
await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
return Ok(AuthRoles.Admin);
}
/// <summary>
@ -147,16 +273,9 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
/// </summary>
/// <returns>An Ok response if the logout was successful.</returns>
[HttpGet("Logout")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public async Task<ActionResult> Logout()
{
SetRefreshToken("", DateTimeOffset.MinValue);
SetFirstToken("", DateTimeOffset.MinValue);
SetAuthToken("", DateTimeOffset.MinValue);
await auth.LogoutAsync(Fingerprint);
await auth.LogoutAsync(GetCookieParams(), HttpContext);
return Ok();
}
@ -175,13 +294,16 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[BadRequestResponse]
public ActionResult<string> RenewPassword([FromBody] string? password = null)
{
var passwordPolicy = generalConfig.Value.PasswordPolicy;
var passwordPolicyService = new PasswordPolicyService(passwordPolicy);
if (string.IsNullOrEmpty(password))
password = string.Empty;
else if (!PasswordHashService.HasPasswordInPolicySecurity(password))
throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character.");
else
passwordPolicyService.ValidatePasswordOrThrow(password);
while (!PasswordHashService.HasPasswordInPolicySecurity(password))
password = GeneratorKey.GenerateAlphaNumeric(16, includes: "!@#%^");
while (!passwordPolicyService.TryValidatePassword(password))
password = GeneratorKey.GenerateAlphaNumeric(passwordPolicy.MinimumLength + 2, includes: "!@#%^");
var (salt, hash) = passwordService.HashPassword(password);
@ -193,4 +315,4 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
return Ok(password);
}
}
}

View File

@ -0,0 +1,219 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1.Configuration;
[ApiVersion("1.0")]
public class ScheduleController(ILogger<ScheduleController> logger, IOptionsSnapshot<GeneralConfig> config, UberDbContext dbContext, IServiceProvider provider) : ConfigurationBaseController
{
/// <summary>
/// Retrieves the cron update schedule and calculates the next scheduled tasks based on the provided depth.
/// </summary>
/// <param name="depth">The depth of the next tasks to retrieve.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
[HttpGet("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery][Range(0, 10)] int depth = 5)
{
var cronExpression = CronExpression.Parse(config.Value.ScheduleSettings!.CronUpdateSchedule);
var nextTasks = config.Value.ScheduleSettings!.CronUpdateSkipDateList.GetNextTask(cronExpression, depth);
return new CronUpdateScheduleResponse()
{
Cron = config.Value.ScheduleSettings!.CronUpdateSchedule,
NextStart = nextTasks.Select(x => DateTime.SpecifyKind(x.DateTime, DateTimeKind.Local)).ToList()
};
}
/// <summary>
/// Updates the cron update schedule with the provided cron expression.
/// </summary>
/// <param name="cron">The cron expression to set as the new schedule.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided cron expression is invalid.</exception>
[HttpPost("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery] string cron)
{
cron = cron.Trim();
if (!CronExpression.TryParse(cron, CronFormat.Standard, out _))
throw new ControllerArgumentException("Incorrect cron value.");
if (config.Value.ScheduleSettings!.CronUpdateSchedule == cron)
return CronUpdateSchedule();
config.Value.ScheduleSettings!.CronUpdateSchedule = cron;
config.Value.SaveSetting();
return CronUpdateSchedule();
}
/// <summary>
/// Retrieves the start term date from the configuration.
/// </summary>
/// <returns>Start term date.</returns>
[HttpGet("StartTerm")]
public ActionResult<DateOnly> StartTerm() =>
config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Updates the start term date in the configuration.
/// </summary>
/// <param name="startTerm">The new start term date to set.</param>
/// <param name="force">If true, forces an update by deleting all existing lessons.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the start term date is more than 6 months in the past or future.</exception>
[HttpPost("StartTerm")]
public ActionResult StartTerm([FromQuery] DateOnly startTerm, [FromQuery] bool force = false)
{
var differentByTime = DateTime.Now - startTerm.ToDateTime(new TimeOnly(0, 0, 0));
if (differentByTime > TimeSpan.FromDays(190) || differentByTime.Multiply(-1) > TimeSpan.FromDays(190))
throw new ControllerArgumentException("The semester can't start more than 6 months from now, and it can't have started more than 6 months ago either.");
config.Value.ScheduleSettings!.StartTerm = startTerm;
config.Value.SaveSetting();
if (!force)
return Ok();
logger.LogWarning("A force update is being performed at the beginning of the semester (all classes will be deleted).");
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
dbContext.SaveChanges();
return Ok();
}
/// <summary>
/// Retrieves the list of cron update skip dates filtered by the current date.
/// </summary>
/// <returns>Cron update skip dates.</returns>
[HttpGet("CronUpdateSkip")]
public ActionResult<List<CronUpdateSkip>> CronUpdateSkip()
{
var generalConfig = config.Value;
generalConfig.ScheduleSettings!.CronUpdateSkipDateList =
generalConfig.ScheduleSettings.CronUpdateSkipDateList.Filter();
generalConfig.SaveSetting();
return generalConfig.ScheduleSettings!.CronUpdateSkipDateList
.ConvertToDto();
}
/// <summary>
/// Updates the list of cron update skip dates in the configuration.
/// </summary>
/// <param name="cronUpdateDate">The list of cron update skip dates to set.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided list of cron update skip dates is invalid.</exception>
[HttpPost("CronUpdateSkip")]
public ActionResult CronUpdateSkip([FromBody] List<CronUpdateSkip> cronUpdateDate)
{
List<ScheduleSettings.CronUpdateSkip> result;
try
{
result = cronUpdateDate.ConvertFromDto();
}
catch (ArgumentException ex)
{
throw new ControllerArgumentException(ex.Message);
}
config.Value.ScheduleSettings!.CronUpdateSkipDateList = result.Filter();
config.Value.SaveSetting();
return Ok();
}
/// <summary>
/// Uploads schedule files and initiates synchronization.
/// </summary>
/// <param name="files">The list of schedule files to upload.</param>
/// <param name="defaultCampus">The default campus for each uploaded file. Must match the number of files.</param>
/// <param name="force">If true, removes all existing lessons before synchronization. Default is false.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">
/// Thrown if:
/// - No files are provided.
/// - The number of default campuses does not match the number of files.
/// - Any default campus is null or empty.
/// </exception>
[HttpPost("Upload")]
public async Task<ActionResult> UploadScheduleFiles(List<IFormFile>? files, [FromQuery] string[]? defaultCampus, [FromQuery] bool force = false)
{
if (files == null || files.Count == 0)
throw new ControllerArgumentException("No files were found.");
if (defaultCampus == null || files.Count != defaultCampus.Length)
throw new ControllerArgumentException("No default campuses are specified for the file.");
if (defaultCampus.Any(string.IsNullOrEmpty))
throw new ControllerArgumentException("Each file should have a default campus.");
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
if (!Directory.Exists(tempDirectory))
Directory.CreateDirectory(tempDirectory);
List<(string, string)> filePaths = [];
for (var i = 0; i < files.Count; i++)
{
if (files[i].Length <= 0)
continue;
var filePath = Path.Combine(tempDirectory, files[i].FileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await files[i].CopyToAsync(stream);
filePaths.Add((filePath, defaultCampus[i]));
}
if (force)
{
dbContext.Lessons.RemoveRange(await dbContext.Lessons.ToListAsync());
await dbContext.SaveChangesAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
ThreadPool.QueueUserWorkItem(async void (_) =>
{
try
{
using var scope = scopeFactory.CreateScope();
var sync = (ScheduleSynchronizer)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, typeof(ScheduleSynchronizer));
await sync.StartSync(filePaths, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
return Ok();
}
}

View File

@ -6,6 +6,7 @@ using Mirea.Api.DataAccess.Application.Cqrs.Discipline.Queries.GetDisciplineList
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -23,7 +24,8 @@ public class DisciplineController(IMediator mediator) : BaseController
/// <returns>Paginated list of disciplines.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetDisciplineListQuery()
{

View File

@ -5,6 +5,7 @@ using Mirea.Api.DataAccess.Application.Cqrs.Faculty.Queries.GetFacultyList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -22,7 +23,8 @@ public class FacultyController(IMediator mediator) : BaseController
/// <returns>Paginated list of faculties.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetFacultyListQuery()
{

View File

@ -7,6 +7,7 @@ using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -37,7 +38,8 @@ public class GroupController(IMediator mediator) : BaseController
/// <returns>A list of groups.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetGroupListQuery()
{

View File

@ -40,7 +40,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
/// <returns>Excel file</returns>
[HttpPost("ImportToExcel")]
[Produces("application/vnd.ms-excel")]
public async Task<IActionResult> ImportToExcel([FromBody] ScheduleRequest request)
public async Task<FileStreamResult> ImportToExcel([FromBody] ScheduleRequest request)
{
var result = (await mediator.Send(new GetScheduleListQuery
{
@ -48,18 +48,16 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
})).Schedules;
if (result.Count == 0)
return NoContent();
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("Расписание");
int row = 1;
int col = 1;
var row = 1;
var col = 1;
worksheet.Cells[row, col++].Value = "День";
worksheet.Cells[row, col++].Value = "Пара";
@ -79,6 +77,8 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod;
var ruCulture = new CultureInfo("ru-RU");
foreach (var dto in result.GroupBy(s => new
{
s.DayOfWeek,
@ -100,7 +100,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
g.First().Campus,
g.First().Professors,
Groups = string.Join('\n', g.Select(x => x.Group)),
IsExclude = g.First().IsExcludedWeeks,
g.First().TypeOfOccupations,
g.First().Weeks
@ -109,7 +109,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
{
// День
worksheet.Cells[row, col++].Value =
$"{(int)dto.DayOfWeek} [{CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]";
$"{(int)dto.DayOfWeek} [{ruCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]";
// Пара
worksheet.Cells[row, col++].Value = dto.PairNumber + " п";
@ -118,7 +118,7 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
worksheet.Cells[row, col++].Value = $"[{(dto.IsEven ? 2 : 1)}] {(dto.IsEven ? "Четная" : "Нечетная")}";
// Время
worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(CultureInfo.CurrentCulture);
worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(ruCulture);
// Группа
worksheet.Cells[row, col].Style.WrapText = true;

View File

@ -0,0 +1,43 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LessonTypeController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of type of occupation.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of type of occupation.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<LessonTypeResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetTypeOfOccupationListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.TypeOfOccupations
.Select(f => new LessonTypeResponse()
{
Id = f.Id,
Name = f.Name
})
);
}
}

View File

@ -6,7 +6,9 @@ using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorDetail
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -24,7 +26,8 @@ public class ProfessorController(IMediator mediator) : BaseController
/// <returns>A list of professors.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<ProfessorResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<ProfessorResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetProfessorListQuery()
{
@ -78,10 +81,10 @@ public class ProfessorController(IMediator mediator) : BaseController
[HttpGet("{name:required}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ProfessorResponse>>> GetDetails(string name)
public async Task<ActionResult<List<ProfessorResponse>>> GetDetails([MinLength(4)] string name)
{
if (string.IsNullOrEmpty(name) || name.Length < 4)
return BadRequest($"The minimum number of characters is 4 (current: {name.Length}).");
throw new ControllerArgumentException($"The minimum number of characters is 4 (current: {name.Length}).");
var result = await mediator.Send(new GetProfessorInfoSearchQuery()
{

View File

@ -8,7 +8,8 @@ using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Configuration.Model;
using System;
using System.Collections.Generic;
@ -50,16 +51,13 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
if ((request.Groups == null || request.Groups.Length == 0) &&
(request.Disciplines == null || request.Disciplines.Length == 0) &&
(request.Professors == null || request.Professors.Length == 0) &&
(request.LectureHalls == null || request.LectureHalls.Length == 0))
(request.LectureHalls == null || request.LectureHalls.Length == 0) &&
(request.LessonType == null || request.LessonType.Length == 0))
{
return BadRequest(new ErrorResponse()
{
Error = "At least one of the arguments must be selected."
+ (request.IsEven.HasValue
? $" \"{nameof(request.IsEven)}\" is not a strong argument"
: string.Empty),
Code = StatusCodes.Status400BadRequest
});
throw new ControllerArgumentException("At least one of the arguments must be selected."
+ (request.IsEven.HasValue
? $" \"{nameof(request.IsEven)}\" is not a strong argument"
: string.Empty));
}
var result = (await mediator.Send(new GetScheduleListQuery
@ -68,8 +66,9 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
})).Schedules;
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
if (result.Count == 0)
NoContent();
@ -104,6 +103,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified group.</returns>
[HttpGet("GetByGroup/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -113,14 +113,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = [id],
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -131,6 +133,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified professor.</returns>
[HttpGet("GetByProfessor/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -140,14 +143,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = [id],
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -158,6 +163,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified lecture hall.</returns>
[HttpGet("GetByLectureHall/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -167,14 +173,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null) =>
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = [id]
LectureHalls = [id],
LessonType = lessonType
});
/// <summary>
@ -185,6 +193,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="groups">An array of group IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified discipline.</returns>
[HttpGet("GetByDiscipline/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -194,13 +203,15 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = [id],
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
}

View File

@ -0,0 +1,86 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Configuration.Model;
using QRCoder;
using System;
using System.Drawing;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class SecurityController(IOptionsSnapshot<GeneralConfig> generalConfig) : BaseController
{
/// <summary>
/// Generates an SVG QR code for TOTP setup with customizable colors and size.
/// </summary>
/// <param name="totpKey">The TOTP secret key to embed in the QR code.</param>
/// <param name="label">The label to display with the QR code (usually username or email).</param>
/// <param name="backgroundColor">Background color for the QR code in hex format (e.g., "#FFFFFF" for white). Defaults to transparent.</param>
/// <param name="foregroundColor">Foreground color for the QR code in hex format (e.g., "#000000" for black), defaults to black.</param>
/// <param name="size">The pixel size of the QR code image (width and height).</param>
/// <param name="errorCorrectionLevel">Error correction level (low, medium, high, or very high). Valid values: L, M, Q, H.</param>
/// <returns>An SVG string of the generated QR code.</returns>
[HttpGet("GenerateTotpQrCode")]
[Produces("image/svg+xml")]
[MaintenanceModeIgnore]
public ContentResult GenerateTotpQrCode(
[FromQuery] string totpKey,
[FromQuery] string label,
[FromQuery] string? backgroundColor = null,
[FromQuery] string foregroundColor = "#000000",
[FromQuery] int size = 250,
[FromQuery] string? errorCorrectionLevel = "M")
{
try
{
var bgColor = string.IsNullOrEmpty(backgroundColor) ? Color.Transparent : ColorTranslator.FromHtml(backgroundColor);
var fgColor = ColorTranslator.FromHtml(foregroundColor);
var eccLevel = errorCorrectionLevel?.ToUpper() switch
{
"L" => QRCodeGenerator.ECCLevel.L,
"Q" => QRCodeGenerator.ECCLevel.Q,
"H" => QRCodeGenerator.ECCLevel.H,
_ => QRCodeGenerator.ECCLevel.M
};
var issuer = Uri.EscapeDataString("Mirea Schedule");
// Generate TOTP URI (otpauth://totp/issuer:username?secret=KEY&issuer=issuer)
var totpUri = $"otpauth://totp/{issuer}:{label}?secret={totpKey}&issuer={issuer}";
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(totpUri, eccLevel);
using var qrCode = new SvgQRCode(qrCodeData);
var svgImage = qrCode.GetGraphic(
pixelsPerModule: size / 25,
darkColorHex: $"#{fgColor.R:X2}{fgColor.G:X2}{fgColor.B:X2}",
lightColorHex: $"#{bgColor.R:X2}{bgColor.G:X2}{bgColor.B:X2}"
);
return Content(svgImage, "image/svg+xml");
}
catch (Exception ex)
{
throw new ControllerArgumentException($"Failed to generate QR code: {ex.Message}");
}
}
/// <summary>
/// Retrieves the current password policy for user authentication.
/// </summary>
/// <returns>
/// The current password policy
/// </returns>
[HttpGet("PasswordPolicy")]
[MaintenanceModeIgnore]
public ActionResult<PasswordPolicy> PasswordPolicy() =>
Ok(generalConfig.Value.PasswordPolicy.ConvertToDto());
}

View File

@ -5,58 +5,65 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0-rc4</Version>
<AssemblyVersion>1.0.2.4</AssemblyVersion>
<FileVersion>1.0.2.4</FileVersion>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType>
<InvariantGlobalization>false</InvariantGlobalization>
<UserSecretsId>65cea060-88bf-4e35-9cfb-18fc996a8f05</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
<SignAssembly>False</SignAssembly>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>docs.xml</DocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="8.0.1" />
<PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="EPPlus" Version="7.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="9.0.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="EPPlus" Version="7.6.1" />
<PackageReference Include="EPPlus.System.Drawing" Version="8.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.13" />
<PackageReference Include="Microsoft.Build.Framework" Version="17.13.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.10">
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.1.2" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.IdentityModel.Protocols" Version="8.6.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.6.1" />
<PackageReference Include="Mirea.Tools.Schedule.Parser" Version="1.2.5" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.6" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="System.CodeDom" Version="8.0.0" />
<PackageReference Include="System.Composition" Version="8.0.0" />
<PackageReference Include="System.Composition.TypedParts" Version="8.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="8.103.6" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.31" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.CodeDom" Version="9.0.2" />
<PackageReference Include="System.Composition" Version="9.0.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.2" />
<PackageReference Include="System.Drawing.Common" Version="9.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.2" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.2" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.2" />
<PackageReference Include="System.Threading.Channels" Version="9.0.2" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="9.103.7.2" />
</ItemGroup>
<ItemGroup>

View File

@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@ -58,7 +60,10 @@ public class Program
builder.Host.AddCustomSerilog();
AddDatabase(builder.Services, builder.Configuration, healthCheckBuilder);
builder.Services.AddControllers();
builder.Services.AddControllers(options =>
{
options.Filters.Add(new ProducesResponseTypeAttribute(StatusCodes.Status500InternalServerError));
});
builder.Services.AddSingleton<IMaintenanceModeNotConfigureService, MaintenanceModeNotConfigureService>();
builder.Services.AddSingleton<IMaintenanceModeService, MaintenanceModeService>();
@ -76,9 +81,7 @@ public class Program
policy.AllowAnyMethod();
policy.AllowAnyHeader();
policy.AllowCredentials();
#if DEBUG
policy.WithOrigins("http://localhost:4200");
#endif
policy.SetIsOriginAllowed(_ => true);
});
});

View File

@ -6,9 +6,11 @@ using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Sync.Common;
using Mirea.Tools.Schedule.Parser.Domain;
using Mirea.Tools.Schedule.WebParser;
using Mirea.Tools.Schedule.WebParser.Common.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@ -19,7 +21,8 @@ using Group = Mirea.Api.DataAccess.Domain.Schedule.Group;
namespace Mirea.Api.Endpoint.Sync;
internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSnapshot<GeneralConfig> config, ILogger<ScheduleSynchronizer> logger, IMaintenanceModeService maintenanceMode)
internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSnapshot<GeneralConfig> config, ILogger<ScheduleSynchronizer> logger,
IMaintenanceModeService maintenanceMode)
{
private readonly DataRepository<Campus> _campuses = new([.. dbContext.Campuses]);
private readonly DataRepository<Discipline> _disciplines = new([.. dbContext.Disciplines]);
@ -120,7 +123,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
{
hall = [];
campuses = [];
for (int i = 0; i < groupInfo.Campuses.Length; i++)
for (var i = 0; i < groupInfo.Campuses.Length; i++)
{
var campus = groupInfo.Campuses[i];
campuses.Add(_campuses.GetOrCreate(
@ -151,7 +154,7 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
Name = groupInfo.Discipline
});
var lesson = _lessons.GetOrCreate(l =>
Lesson lesson = _lessons.GetOrCreate(l =>
l.IsEven == groupInfo.IsEven &&
l.DayOfWeek == groupInfo.Day &&
l.PairNumber == groupInfo.Pair &&
@ -182,9 +185,9 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
return lesson;
});
int maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
var maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
for (int i = 0; i < maxValue; i++)
for (var i = 0; i < maxValue; i++)
{
var prof = professor?.ElementAtOrDefault(i);
var lectureHall = hall?.ElementAtOrDefault(i);
@ -219,33 +222,15 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
}
public async Task StartSync(CancellationToken cancellationToken)
private async Task Sync(Func<CancellationToken, Task<List<GroupResult>>> parseDataAsync, CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod;
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.", nameof(pairPeriods), nameof(startTerm));
return;
}
Stopwatch watch = new();
watch.Start();
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
logger.LogDebug("Start parsing schedule");
var data = await parser.ParseAsync(cancellationToken);
var data = await parseDataAsync(cancellationToken);
watch.Stop();
var parsingTime = watch.ElapsedMilliseconds;
@ -279,11 +264,255 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
maintenanceMode.DisableMaintenanceMode();
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
nameof(pairPeriods),
nameof(startTerm));
return;
}
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
await Sync(parser.ParseAsync, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(List<(string File, string Campus)> files, CancellationToken cancellationToken)
{
await Task.Yield();
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
if (pairPeriods == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} variable is not initialized.",
nameof(pairPeriods));
return;
}
try
{
Task<List<GroupResult>> ParseTask(CancellationToken ctx)
{
var mappedData = new ConcurrentBag<GroupResult>();
ParallelOptions options = new() { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.ForEach(files, options, (file) =>
{
var parser = new Tools.Schedule.Parser.Parser();
var result = ConvertToGroupResults(parser.Parse(file.File, pairPeriods), file.Campus);
foreach (var item in result) mappedData.Add(item);
});
return Task.FromResult(mappedData.ToList());
}
await Sync(ParseTask, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
private static List<GroupResult> ConvertToGroupResults(IEnumerable<GroupInfo> groups, string campusDefault, CancellationToken cancellationToken = default)
{
var result = new List<GroupResult>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var day in group.Days)
{
foreach (var pair in day.Lessons)
{
foreach (var lesson in pair.Value)
{
if (string.IsNullOrWhiteSpace(lesson.TypeOfOccupation))
continue;
var (weeks, isExclude) = ParseWeeks(lesson.Discipline);
var (lectureHalls, campuses) = ParseLectureHalls(lesson.LectureHall, campusDefault);
var groupResult = new GroupResult
{
Day = day.DayOfWeek,
Pair = pair.Key,
IsEven = lesson.IsEven,
Group = group.GroupName,
Discipline = NormalizeDiscipline(lesson.Discipline),
Professor = ParseProfessors(lesson.Professor),
TypeOfOccupation = lesson.TypeOfOccupation,
LectureHalls = lectureHalls,
Campuses = campuses,
SpecialWeek = weeks,
IsExclude = isExclude
};
result.Add(groupResult);
}
}
}
}
return result;
}
private static string[]? ParseProfessors(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace(",", " "),
@"\s+", " ").Trim();
return ProfessorFullName().Matches(normalized)
.Select(m => $"{m.Groups["surname"].Value} {m.Groups["initials"].Value}".Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
}
private static (int[]? weeks, bool? isExclude) ParseWeeks(string discipline)
{
var match = ParseSpecificWeeks().Match(discipline);
if (!match.Success) return (null, null);
var numbers = new List<int>();
var ranges = match.Groups[2].Value.Split(',');
foreach (var range in ranges)
{
if (range.Contains('-'))
{
var parts = range.Split('-');
if (int.TryParse(parts[0], out var start) &&
int.TryParse(parts[1], out var end))
{
numbers.AddRange(Enumerable.Range(start, end - start + 1));
}
}
else
if (int.TryParse(range, out var num)) numbers.Add(num);
}
return (
weeks: numbers.Distinct().OrderBy(x => x).ToArray(),
isExclude: match.Groups[1].Success
);
}
private static string NormalizeDiscipline(string input)
{
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace("\r", " "),
@"\s{2,}", " ");
normalized = Regex.Replace(normalized,
@"(\S+)\s(\S{3,})",
"$1 $2");
normalized = ParseSpecificWeeks().Replace(normalized, "");
return normalized.Trim();
}
private static (string[]? lectureHalls, string[]? campuses) ParseLectureHalls(string? input, string defaultCampus)
{
if (string.IsNullOrWhiteSpace(input))
return (null, null);
var matches = ParseLectureCampus().Matches(input);
var lectureHalls = new List<string>();
var campuses = new List<string>();
foreach (Match match in matches)
{
if (match.Groups["lectureWithCampus"].Success)
{
var raw = match.Value.Split('(');
var campus = raw.LastOrDefault()?.Trim(')').Trim();
var lecture = raw.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(campus) || string.IsNullOrEmpty(lecture))
continue;
campuses.Add(campus);
lectureHalls.Add(lecture);
}
else if (match.Groups["lecture"].Success)
{
var lecture = match.Value.Trim();
if (string.IsNullOrEmpty(lecture))
continue;
campuses.Add(defaultCampus);
lectureHalls.Add(lecture);
}
}
return (
lectureHalls: lectureHalls.ToArray(),
campuses: campuses.ToArray()
);
}
[GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")]
private static partial Regex OnlyGroupName();
[GeneratedRegex(@"(?<surname>[А-ЯЁ][а-яё]+(-[А-ЯЁ][а-яё]+)?)\s*(?<initials>[А-ЯЁ]\.[А-ЯЁ]?\.?)?", RegexOptions.IgnorePatternWhitespace)]
private static partial Regex ProfessorFullName();
[GeneratedRegex(@"([Кк]р\.?)?\s*((\d+-\d+|\d+)(,\s*\d+(-\d+)?)*)\s*[Нн]\.?", RegexOptions.IgnoreCase, "ru-RU")]
private static partial Regex ParseSpecificWeeks();
[GeneratedRegex(@"(?<lectureWithCampus>[^,.\n]+\s?\([А-Яа-яA-Za-z]+-?\d+\))|(?<lecture>[^,.\n]+)")]
private static partial Regex ParseLectureCampus();
}

147
README.md
View File

@ -11,48 +11,125 @@ The main task is to provide convenient and flexible tools for accessing the sche
The purpose of this project is to provide convenient and flexible tools for obtaining schedule data.
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to provide users with a simple and effective tool for accessing information about class schedules.
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to
provide users with a simple and effective tool for accessing information about class schedules.
Developing your own API and using your own tools for downloading and processing data allows you to ensure the reliability, flexibility and extensibility of the application functionality.
Developing your own API and using your own tools for downloading and processing data allows you to ensure the
reliability, flexibility and extensibility of the application functionality.
## Features
1. **Flexible API**: The API provides a variety of methods for accessing schedule data. Unlike competitors that provide a limited set of endpoints, this application provides a wider range of functionality, allowing you to get data about groups, campuses, faculties, classrooms and teachers. You can get all the data at once or select specific IDs with the details that are needed.
1. **Flexible API**: The API provides a variety of methods for accessing schedule data. Unlike competitors that provide
a limited set of endpoints, this application provides a wider range of functionality, allowing you to get data about
groups, campuses, faculties, classrooms and teachers. You can get all the data at once or select specific IDs with
the details that are needed.
2. **Database Providers**: The application provides the capability of various database providers.
3. **Using self-written packages**: The project uses two proprietary NuGet packages. One of them is designed for parsing schedules, and the other is for downloading Excel spreadsheets from external sites.
3. **Using self-written packages**: The project uses two proprietary NuGet packages. One of them is designed for parsing
schedules, and the other is for downloading Excel spreadsheets from external sites.
## Project status
The project is under development. Further development will be aimed at expanding the functionality and improving the user experience.
The project is under development. Further development will be aimed at expanding the functionality and improving the
user experience.
# Environment Variables
This table provides information about the environment variables that are used in the application. These variables are stored in the [.env](.env) file.
This table provides information about the environment variables that are used in the application. These variables are
stored in the [.env](.env) file.
In addition to these variables, you also need to fill in a file with settings in json format. The web application provided by this project already has everything necessary to configure the file in the Client-Server communication format via the controller. If you need to get the configuration file otherwise, then you need to refer to the classes that provide configuration-related variables.
In addition to these variables, you also need to fill in a file with settings in json format. The web application
provided by this project already has everything necessary to configure the file in the Client-Server communication
format via the controller. If you need to get the configuration file otherwise, then you need to refer to the classes
that provide configuration-related variables.
Please note that the application will not work correctly if you do not fill in the required variables.
| Variable | Default | Description | Required |
|---------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| PATH_TO_SAVE | Current folder | The path to save the data. Saving logs (if the full path is not specified), databases (if Sqlite), and other data that should be saved in a place other than where the program is launched. | ✔ |
| SECURITY_SIGNING_TOKEN | ❌ | JWT signature token. This token will be used to create and verify the signature of JWT tokens. The token must be equal to 64 characters. | ✔ |
| SECURITY_ENCRYPTION_TOKEN | | Token for JWT encryption. This token will be used to encrypt and decrypt JWT tokens. The token must be equal to 32 characters. | ✔ |
| SECURITY_LIFE_TIME_RT | 1440 | Time in minutes after which the Refresh Token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_JWT | 15 | Time in minutes after which the JWT token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_1_FA | 15 | Time in minutes after which the token of the first factor will become invalid. | ❌ |
| SECURITY_JWT_ISSUER | ❌ | An identifier that points to the server that created the token. | ✔ |
| SECURITY_JWT_AUDIENCE | ❌ | ID of the audience for which the token is intended. | ✔ |
| SECURITY_HASH_ITERATION | ❌ | The number of iterations used to hash passwords in the Argon2 algorithm. | ✔ |
| SECURITY_HASH_MEMORY | ❌ | The amount of memory used to hash passwords in the Argon2 algorithm. | |
| SECURITY_HASH_PARALLELISM | ❌ | Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash. | ✔ |
| SECURITY_HASH_SIZE | 32 | The size of the output hash generated by the password hashing algorithm. | |
| SECURITY_HASH_TOKEN | ❌ | Additional protection for Argon2. We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token. | |
| SECURITY_SALT_SIZE | 16 | The size of the salt used to hash passwords. The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks. | ❌ |
### General Configuration
| Variable | Default | Description | Required |
|------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| PATH_TO_SAVE | ./ | The path to save the data. Saving logs, databases (if Sqlite), and other data that should be saved in a different place. REQUIRED if the application is inside the container. | ✔ |
| ACTUAL_SUB_PATH | | The actual sub path to the API. If the specified path ends with "/api", the system will avoid duplicating "api" in the final URL. | ❌ |
| SWAGGER_SUB_PATH | swagger | The sub path to the Swagger documentation. | ❌ |
| INTERNAL_PORT | 8080 | Specify the internal port on which the server will listen. | ❌ |
### Security Configuration
| Variable | Default | Description | Required |
|---------------------------|---------|--------------------------------------------------------------------------------------------------------------------------------|----------|
| SECURITY_SIGNING_TOKEN | | JWT signature token. This token will be used to create and verify the signature of JWT tokens. Must be equal to 64 characters. | |
| SECURITY_ENCRYPTION_TOKEN | | Token for JWT encryption. This token will be used to encrypt and decrypt JWT tokens. Must be equal to 32 characters. | |
| SECURITY_LIFE_TIME_RT | 1440 | Time in minutes, which indicates after which time the Refresh Token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_JWT | 15 | The time in minutes for the JWT to be valid. | ❌ |
| SECURITY_LIFE_TIME_1_FA | 15 | Time in minutes after which the token of the first factor will become invalid. | ❌ |
| SECURITY_JWT_ISSUER | | An identifier that points to the server that created the token. | ✔ |
| SECURITY_JWT_AUDIENCE | | ID of the audience for which the token is intended. | ✔ |
### Hashing Configuration
| Variable | Default | Description | Required |
|---------------------------|---------|-------------------------------------------------------------------------------------------------------------------|----------|
| SECURITY_HASH_ITERATION | | The number of iterations used to hash passwords in the Argon2 algorithm. At least 10 is recommended for security. | ✔ |
| SECURITY_HASH_MEMORY | 65536 | The amount of memory used to hash passwords in the Argon2 algorithm (in KB). | ✔ |
| SECURITY_HASH_PARALLELISM | | Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash. | ✔ |
| SECURITY_HASH_SIZE | 32 | The size of the output hash generated by the password hashing algorithm. | ✔ |
| SECURITY_HASH_TOKEN | | Additional protection for Argon2. Recommended to install a token for added security. | ❌ |
| SECURITY_SALT_SIZE | 16 | The size of the salt used to hash passwords. | ✔ |
### OAuth2 Configuration
To set up the `redirect URL` when registering and logging in using OAuth 2, use the following format:
```
"{schema}://{domain}{portString}{ACTUAL_SUB_PATH}/api/v1/Auth/OAuth2"
```
**Where:**
- `{schema}` is the protocol you are using (`http` or `https`).
- `{domain}` is your domain (for example, `mydomain.com` or IP address).
- `{portString}` is a port string that is only needed if your application is running on a non-standard port (for
example, `:8080`). If you use standard ports (`80` for `http` and `443` for `https`), this parameter can be omitted.
- `{ACTUAL_SUB_PATH}` is the path to your API that you specify in the settings. If it ends with `/api`, then don't add `/api` at the end of the URL.
**Examples:**
- If you have `ACTUAL_SUB_PATH = my_subpath/api`, then the `redirect URL` will be:
- `https://mydomain.com/my_subpath/api/v1/Auth/OAuth2`
- If your application is on a local server and uses a non-standard port:
- `http://192.168.1.5:8080/my_subpath/api/v1/Auth/OAuth2 `
**Important:**
- If your application is not located behind a proxy server, then the parameter `{ACTUAL_SUB_PATH}` does not need to be
specified.
#### Google
| Variable | Default | Description | Required |
|----------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------|----------|
| GOOGLE_CLIENT_ID | | The client ID provided by Google when you register your application for OAuth. It is necessary for enabling Google login functionality. | ✔ |
| GOOGLE_CLIENT_SECRET | | The client secret provided by Google, used alongside the client ID to authenticate your application. | ✔ |
#### Yandex
| Variable | Default | Description | Required |
|----------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------|----------|
| YANDEX_CLIENT_ID | | The client ID provided by Yandex when you register your application for OAuth. It is required for enabling Yandex login functionality. | ✔ |
| YANDEX_CLIENT_SECRET | | The client secret provided by Yandex, used alongside the client ID to authenticate your application. | ✔ |
#### MailRu
| Variable | Default | Description | Required |
|----------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| MAILRU_CLIENT_ID | | The client ID provided by MailRu (Mail.ru Group) when you register your application for OAuth. It is necessary for enabling MailRu login functionality. | ✔ |
| MAILRU_CLIENT_SECRET | | The client secret provided by MailRu, used alongside the client ID to authenticate your application. | ✔ |
# Installation
If you want to make a fork of this project or place the Backend application on your hosting yourself, then follow the instructions below.
If you want to make a fork of this project or place the Backend application on your hosting yourself, then follow the
instructions below.
1. [Docker Installation](#docker-installation)
2. [Docker Self Build](#docker-self-build)
@ -86,11 +163,14 @@ Using the `--name` option, you can specify your container name, for example: `--
With the `-p` option, you can specify the port you need: `-p 80:8080`.
It is necessary to tell the application exactly where to save the data so that it does not disappear when the container is deleted.
It is necessary to tell the application exactly where to save the data so that it does not disappear when the container
is deleted.
To do this, replace the `-v` option, where you need to specify the path to the data on the host first, and then using `:` specify the path inside the container. `-v /nas/mirea/backend:/myfolder`.
To do this, replace the `-v` option, where you need to specify the path to the data on the host first, and then using
`:` specify the path inside the container. `-v /nas/mirea/backend:/myfolder`.
At the same time, do not forget to replace inside [.env](.env) `PATH_TO_SAVE` with what you specify in the `-v` option. In our case, it will be `PATH_TO_SAVE=/myfolder`.
At the same time, do not forget to replace inside [.env](.env) `PATH_TO_SAVE` with what you specify in the `-v` option.
In our case, it will be `PATH_TO_SAVE=/myfolder`.
That's it, the container is running!
@ -106,7 +186,8 @@ docker build -t my-name/mirea-backend:latest .
Where `-t` indicates the name and version of the image. You can specify their `your-name/image-name:version`.
Now the image is ready. To launch the container, refer to [Docker Installation](#docker-installation), do not forget to specify the name of the image that you have built.
Now the image is ready. To launch the container, refer to [Docker Installation](#docker-installation), do not forget to
specify the name of the image that you have built.
## Manual Installation
@ -123,11 +204,14 @@ To install using a pre-built application, follow these steps:
### Install ASP.NET
Installation ASP.NET it depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the
installation instructions.
### Download Package
The latest versions of the packages can be found in [releases](https://git.winsomnia.net/Winsomnia/MireaBackend/releases ). If there is no build for your platform, go [to the Self Build section](#self-build).
The latest versions of the packages can be found
in [releases](https://git.winsomnia.net/Winsomnia/MireaBackend/releases ). If there is no build for your platform,
go [to the Self Build section](#self-build).
### Run
@ -160,7 +244,8 @@ To build your own version of the program, follow these steps:
### Install NET SDK
Installation.The NET SDK depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the
installation instructions.
### Clone The Repository

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Security.Common;
public class CookieNames
{
public const string AccessToken = "access_token";
public const string RefreshToken = "refresh_token";
public const string FingerprintToken = "fingerprint";
}

View File

@ -1,13 +0,0 @@
using System;
namespace Mirea.Api.Security.Common.Domain;
public class AuthToken
{
public required string RefreshToken { get; set; }
public required string UserAgent { get; set; }
public required string Ip { get; set; }
public required string UserId { get; set; }
public required string AccessToken { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,27 @@
using System;
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class AuthToken
{
public AuthToken(RequestContextInfo context)
{
UserAgent = context.UserAgent;
Ip = context.Ip;
Fingerprint = context.Fingerprint;
RefreshToken = context.RefreshToken;
}
public AuthToken()
{
}
public string UserAgent { get; set; } = null!;
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public required string UserId { get; set; }
public required string AccessToken { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,24 @@
using Mirea.Api.Security.Common.Model;
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class FirstAuthToken
{
public FirstAuthToken(RequestContextInfo context)
{
UserAgent = context.UserAgent;
Ip = context.Ip;
Fingerprint = context.Fingerprint;
}
public FirstAuthToken()
{
}
public string UserAgent { get; set; } = null!;
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public required string UserId { get; set; }
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
public string? Secret { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace Mirea.Api.Security.Common.Domain.Caching;
internal class OAuthUserExtension
{
public string? Message { get; set; }
public bool IsSuccess { get; set; }
public required OAuthProvider? Provider { get; set; }
public string? UserAgent { get; set; } = null;
public string? Ip { get; set; } = null;
public string? Fingerprint { get; set; } = null;
public OAuthUser? User { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace Mirea.Api.Security.Common.Domain;
internal class OAuthPayload
{
public required OAuthProvider Provider { get; set; }
public required string Callback { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Security.Common.Domain;
public enum OAuthProvider
{
Google,
Yandex,
MailRu
}

View File

@ -0,0 +1,13 @@
using System;
namespace Mirea.Api.Security.Common.Domain;
internal readonly struct OAuthProviderUrisData
{
public string RedirectUrl { get; init; }
public string TokenUrl { get; init; }
public string UserInfoUrl { get; init; }
public string AuthHeader { get; init; }
public string Scope { get; init; }
public Type UserInfoType { get; init; }
}

View File

@ -0,0 +1,11 @@
namespace Mirea.Api.Security.Common.Domain;
public class OAuthUser
{
public required string Id { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? IconUri { get; set; }
}

View File

@ -1,10 +0,0 @@
namespace Mirea.Api.Security.Common.Domain;
public class PreAuthToken
{
public required string Fingerprint { get; set; }
public required string UserAgent { get; set; }
public required string UserId { get; set; }
public required string Ip { get; set; }
public required string Token { get; set; }
}

View File

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Services;
using System;
using System.Security;
namespace Mirea.Api.Security.Common.Domain;
internal class RequestContextInfo
{
public RequestContextInfo(HttpContext context, Model.CookieOptions cookieOptions)
{
var ipEntity = context.Connection.RemoteIpAddress;
if (string.IsNullOrEmpty(ipEntity?.ToString()))
throw new SecurityException("Ip is required for authorization.");
var ip = ipEntity.MapToIPv4().ToString();
var userAgent = context.Request.Headers.UserAgent.ToString();
var fingerprint = context.Request.Cookies[CookieNames.FingerprintToken];
if (string.IsNullOrEmpty(fingerprint))
{
fingerprint = Guid.NewGuid().ToString().Replace("-", "") + GeneratorKey.GenerateString(32);
cookieOptions.SetCookie(context, CookieNames.FingerprintToken, fingerprint, DateTimeOffset.UtcNow.AddYears(10));
}
UserAgent = userAgent;
Fingerprint = fingerprint;
Ip = ip;
RefreshToken = context.Request.Cookies[CookieNames.RefreshToken] ?? string.Empty;
}
public string UserAgent { get; private set; }
public string Ip { get; private set; }
public string Fingerprint { get; private set; }
public string RefreshToken { get; private set; }
}

Some files were not shown because too many files have changed in this diff Show More