Compare commits
162 Commits
2e48b0067f
...
master
Author | SHA1 | Date | |
---|---|---|---|
95692a6a1f
|
|||
13eb3c0033
|
|||
f6d1543108 | |||
a4721c9739 | |||
46bbc34956 | |||
047ccfa754 | |||
b0d9a67c1c | |||
3eb043b24c | |||
4cd476764d | |||
90b4662dda | |||
e7edc79ebc | |||
aabeed0aa5 | |||
e79ec360ea | |||
31c1d2804d | |||
ea4c8b61e0 | |||
b40e394bcf | |||
885b937b0b | |||
dc08285ec8 | |||
b3a0964aac | |||
7d6b21c5bb | |||
93912caf01 | |||
c725cfed32 | |||
7c7707b1e2 | |||
1687e9d89b | |||
8d1b709b43 | |||
ce6b0f2673 | |||
16afc0bc69 | |||
c9bc6a3565 | |||
ad8f356fc1 | |||
dda0a29300 | |||
369901db78 | |||
a67b72b7fb | |||
2453b2bd51 | |||
5870eef552 | |||
52de98969d | |||
bc86e077bd | |||
03b6560bc4 | |||
5bcb7bfbc1 | |||
38fba5556f | |||
fd26178a24 | |||
7eb307b65e | |||
56c7196100 | |||
92081156cf | |||
6358410f18 | |||
e79ddf220f | |||
c3c9844e2f | |||
206720cd63 | |||
d9f4176aca | |||
1de344ac25 | |||
61a11ea223 | |||
07111b9b61 | |||
538f1d67c8 | |||
233458ed89 | |||
7f87b4d856 | |||
0c6d1c9bfb | |||
516ba5bb8e | |||
9d5007ef3a | |||
c75ac60b0b | |||
5b7412f20f | |||
c4a4478b8c | |||
05166188be | |||
157708d00f | |||
36026b3afb | |||
43edab2912 | |||
dcdd43469b | |||
17fd260068 | |||
97187a8e45 | |||
cfe08dcf9b | |||
ae4d2073c4 | |||
269d976ad4 | |||
5fa545e981 | |||
2ab5dea8ba | |||
5e65aded79 | |||
dfac9ddca8 | |||
c66f3355ec | |||
c12323dc29 | |||
71c31c0bbb | |||
8c51ba83a4 | |||
9ff0f51e19 | |||
408a95e4b3 | |||
2a33ecbf07 | |||
97e50b5331 | |||
d505041c72 | |||
5ff8744a55 | |||
053f01eec1 | |||
e8e94e45a5 | |||
55562a9f00 | |||
57b9819d13 | |||
78254ed23d | |||
202d20bb25 | |||
3e05863aea | |||
b82fbc491f | |||
85722f8552 | |||
9231c4d5ca | |||
7b94f9cc1f | |||
7bafbb95c4 | |||
544ad6e791 | |||
e4b942d062 | |||
f2e79e51f2 | |||
5cc54eac44 | |||
e9ff1cabe8 | |||
fd578aa61e | |||
cff42d0a31 | |||
8250957b85 | |||
39208037f0 | |||
5e072d88c2 | |||
25eddbe776 | |||
74ba4e901a | |||
e760ddae0a | |||
598ebabc5c | |||
08aeb7ea3c | |||
182235c4cd | |||
5437623a20 | |||
2c09122971 | |||
503f5792fb | |||
95627003e5 | |||
a96073d44d | |||
5f36e0f75b | |||
e977de3e4f | |||
65d928ec2d | |||
713bbfa16f | |||
6b5eda7756 | |||
dbd9e1a070 | |||
0dda336de1 | |||
727f5c276e | |||
db70e4dd96 | |||
6831d9c708 | |||
1b24954c3e | |||
c5ba1cfcca | |||
3811d879ab | |||
61dc0a8bc4 | |||
b3b00aa9e1 | |||
6c9af942f4 | |||
23f74b3bdf | |||
eb272baa38 | |||
a0ff624481 | |||
cd6f25deba | |||
0f47a98ad9 | |||
3279ef594b | |||
5bc729eb66 | |||
5317b7b563 | |||
665544236f | |||
f203ee71f0 | |||
d8dbf1562f | |||
dead9f89bb | |||
8c932cf0be | |||
80e74b34c1 | |||
b095ca9749 | |||
8fad070a9c | |||
6c20713d81 | |||
fc5ec1fd54 | |||
ed99fce9b8 | |||
2ccc476686 | |||
84d7b095f0 | |||
4605c81895 | |||
0788c36bd2 | |||
f5dbc46856 | |||
ebec0a2d2b | |||
4fc28378c5 | |||
98ee3c389c | |||
428c2dc3ba | |||
4970dd782a |
278
.editorconfig
Normal file
278
.editorconfig
Normal 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
48
.env
@ -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=
|
||||
|
@ -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
31
.github/workflows/code-analyze.yaml
vendored
Normal 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 }}
|
@ -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 "
|
||||
@ -77,6 +83,13 @@ jobs:
|
||||
-e SECURITY_SALT_SIZE=$SECURITY_SALT_SIZE \
|
||||
-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
|
||||
"
|
||||
|
17
ApiDto/Common/CacheType.cs
Normal file
17
ApiDto/Common/CacheType.cs
Normal 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
|
||||
}
|
24
ApiDto/Common/CronUpdateSkip.cs
Normal file
24
ApiDto/Common/CronUpdateSkip.cs
Normal 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; }
|
||||
}
|
22
ApiDto/Common/DatabaseType.cs
Normal file
22
ApiDto/Common/DatabaseType.cs
Normal 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
|
||||
}
|
17
ApiDto/Common/OAuthAction.cs
Normal file
17
ApiDto/Common/OAuthAction.cs
Normal 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
|
||||
}
|
22
ApiDto/Common/OAuthProvider.cs
Normal file
22
ApiDto/Common/OAuthProvider.cs
Normal 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
|
||||
}
|
32
ApiDto/Common/PasswordPolicy.cs
Normal file
32
ApiDto/Common/PasswordPolicy.cs
Normal 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; }
|
||||
}
|
17
ApiDto/Common/TwoFactorAuthentication.cs
Normal file
17
ApiDto/Common/TwoFactorAuthentication.cs
Normal 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,
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
19
ApiDto/Requests/TwoFactorAuthRequest.cs
Normal file
19
ApiDto/Requests/TwoFactorAuthRequest.cs
Normal 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; }
|
||||
}
|
27
ApiDto/Responses/AvailableOAuthProvidersResponse.cs
Normal file
27
ApiDto/Responses/AvailableOAuthProvidersResponse.cs
Normal 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; }
|
||||
}
|
@ -28,4 +28,4 @@ public class CampusDetailsResponse
|
||||
/// Gets or sets the address of the campus (optional).
|
||||
/// </summary>
|
||||
public string? Address { get; set; }
|
||||
}
|
||||
}
|
29
ApiDto/Responses/Configuration/CacheResponse.cs
Normal file
29
ApiDto/Responses/Configuration/CacheResponse.cs
Normal 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; }
|
||||
}
|
23
ApiDto/Responses/Configuration/CronUpdateScheduleResponse.cs
Normal file
23
ApiDto/Responses/Configuration/CronUpdateScheduleResponse.cs
Normal 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; }
|
||||
}
|
49
ApiDto/Responses/Configuration/DatabaseResponse.cs
Normal file
49
ApiDto/Responses/Configuration/DatabaseResponse.cs
Normal 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; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Mirea.Api.Dto.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Represents detailed information about a faculty.
|
||||
/// </summary>
|
||||
public class FacultyDetailsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the faculty.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the faculty.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the campus to which the faculty belongs (optional).
|
||||
/// </summary>
|
||||
public int? CampusId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the campus to which the faculty belongs (optional).
|
||||
/// </summary>
|
||||
public string? CampusName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the code name of the campus to which the faculty belongs (optional).
|
||||
/// </summary>
|
||||
public string? CampusCode { get; set; }
|
||||
}
|
@ -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
|
||||
{
|
||||
@ -18,9 +18,4 @@ public class FacultyResponse
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the campus to which the faculty belongs (optional).
|
||||
/// </summary>
|
||||
public int? CampusId { get; set; }
|
||||
}
|
21
ApiDto/Responses/LessonTypeResponse.cs
Normal file
21
ApiDto/Responses/LessonTypeResponse.cs
Normal 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; }
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
17
ApiDto/Responses/TotpKeyResponse.cs
Normal file
17
ApiDto/Responses/TotpKeyResponse.cs
Normal 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; }
|
||||
}
|
35
ApiDto/Responses/UserResponse.cs
Normal file
35
ApiDto/Responses/UserResponse.cs
Normal 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; }
|
||||
}
|
@ -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}"
|
||||
|
12
Dockerfile
12
Dockerfile
@ -1,18 +1,26 @@
|
||||
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
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl --fail http://localhost:8080/health || exit 1
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
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
|
||||
|
@ -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);
|
@ -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; }
|
||||
|
@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Common.Attributes;
|
||||
@ -9,11 +10,21 @@ public class LocalhostAttribute : ActionFilterAttribute
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
var ip = context.HttpContext.Connection.RemoteIpAddress;
|
||||
if (ip == null || !IPAddress.IsLoopback(ip))
|
||||
|
||||
if (ip == null)
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
base.OnActionExecuting(context);
|
||||
|
||||
var isRunningInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")?.ToLower() == "true";
|
||||
|
||||
if (IPAddress.IsLoopback(ip) || (isRunningInContainer && ip.ToString().StartsWith("172.")))
|
||||
{
|
||||
base.OnActionExecuting(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
@ -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;
|
@ -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);
|
@ -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;
|
||||
|
8
Endpoint/Common/Exceptions/ServerUnavailableException.cs
Normal file
8
Endpoint/Common/Exceptions/ServerUnavailableException.cs
Normal 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;
|
||||
}
|
@ -3,6 +3,5 @@
|
||||
public interface IMaintenanceModeNotConfigureService
|
||||
{
|
||||
bool IsMaintenanceMode { get; }
|
||||
|
||||
void DisableMaintenanceMode();
|
||||
}
|
@ -3,8 +3,6 @@
|
||||
public interface IMaintenanceModeService
|
||||
{
|
||||
bool IsMaintenanceMode { get; }
|
||||
|
||||
void EnableMaintenanceMode();
|
||||
|
||||
void DisableMaintenanceMode();
|
||||
}
|
27
Endpoint/Common/MapperDto/AvailableProvidersConverter.cs
Normal file
27
Endpoint/Common/MapperDto/AvailableProvidersConverter.cs
Normal 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();
|
||||
}
|
19
Endpoint/Common/MapperDto/CronUpdateSkipConverter.cs
Normal file
19
Endpoint/Common/MapperDto/CronUpdateSkipConverter.cs
Normal 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();
|
||||
}
|
@ -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));
|
||||
}
|
23
Endpoint/Common/MapperDto/PasswordPolicyConverter.cs
Normal file
23
Endpoint/Common/MapperDto/PasswordPolicyConverter.cs
Normal 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
|
||||
};
|
||||
}
|
@ -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)
|
||||
};
|
||||
}
|
20
Endpoint/Common/MapperDto/UserConverter.cs
Normal file
20
Endpoint/Common/MapperDto/UserConverter.cs
Normal 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
|
||||
};
|
||||
}
|
78
Endpoint/Common/Services/CronUpdateSkipService.cs
Normal file
78
Endpoint/Common/Services/CronUpdateSkipService.cs
Normal 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;
|
||||
}
|
||||
}
|
10
Endpoint/Common/Services/ScheduleSyncManager.cs
Normal file
10
Endpoint/Common/Services/ScheduleSyncManager.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Common.Services;
|
||||
|
||||
public static class ScheduleSyncManager
|
||||
{
|
||||
public static event Action? OnForceSyncRequested;
|
||||
public static void RequestForceSync() =>
|
||||
OnForceSyncRequested?.Invoke();
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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, "")
|
||||
]),
|
||||
|
@ -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)
|
||||
|
@ -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('/')}";
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
using Cronos;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
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;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
|
||||
|
||||
public class ScheduleSyncService : IHostedService, IDisposable
|
||||
{
|
||||
private Timer? _timer;
|
||||
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)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
|
||||
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
|
||||
|
||||
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
|
||||
_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()
|
||||
{
|
||||
_logger.LogInformation("It was requested to synchronize the data immediately.");
|
||||
StopAsync(CancellationToken.None).ContinueWith(_ =>
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
ExecuteTask(null);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnUpdateIntervalRequested()
|
||||
{
|
||||
_logger.LogInformation("It was requested to update the time interval immediately.");
|
||||
StopAsync(CancellationToken.None).ContinueWith(_ =>
|
||||
{
|
||||
StartAsync(CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
private void ScheduleNextRun()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_cronUpdate))
|
||||
{
|
||||
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
|
||||
return;
|
||||
}
|
||||
|
||||
var expression = CronExpression.Parse(_cronUpdate);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
|
||||
|
||||
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, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private async void ExecuteTask(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var syncService = ActivatorUtilities.GetServiceOrCreateInstance<ScheduleSynchronizer>(scope.ServiceProvider);
|
||||
await syncService.StartSync(_cancellationTokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred during schedule synchronization.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScheduleNextRun();
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ScheduleNextRun();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
_timer?.Dispose();
|
||||
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
|
||||
_onChangeUpdateCron?.Dispose();
|
||||
_cancellationTokenSource.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -1,15 +1,20 @@
|
||||
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;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
|
||||
|
||||
public class CustomExceptionHandlerMiddleware(RequestDelegate next)
|
||||
public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<CustomExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
@ -23,38 +28,67 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next)
|
||||
}
|
||||
}
|
||||
|
||||
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
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;
|
||||
}
|
||||
|
||||
if (problemDetails.Status == StatusCodes.Status500InternalServerError)
|
||||
logger.LogError(exception, "Internal server error when processing the request");
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = code;
|
||||
context.Response.StatusCode = problemDetails.Status.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
result = JsonSerializer.Serialize(new ErrorResponse()
|
||||
{
|
||||
Error = exception.Message,
|
||||
Code = code
|
||||
});
|
||||
|
||||
return context.Response.WriteAsync(result);
|
||||
return context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -5,9 +5,9 @@ namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
|
||||
|
||||
public static class ApiVersioningConfiguration
|
||||
{
|
||||
public static void AddCustomApiVersioning(this IServiceCollection services)
|
||||
public static IApiVersioningBuilder AddCustomApiVersioning(this IServiceCollection services)
|
||||
{
|
||||
services.AddApiVersioning(options =>
|
||||
return services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
|
@ -1,23 +1,25 @@
|
||||
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;
|
||||
|
||||
public static class CacheConfiguration
|
||||
{
|
||||
public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration)
|
||||
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 =>
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = cache.ConnectionString;
|
||||
options.InstanceName = "mirea_";
|
||||
});
|
||||
}
|
||||
options.Configuration = cache.ConnectionString;
|
||||
options.InstanceName = "mirea_";
|
||||
});
|
||||
|
||||
healthChecksBuilder?.AddRedis(cache.ConnectionString!, name: "Redis");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@ -11,19 +12,21 @@ namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
|
||||
|
||||
public static class JwtConfiguration
|
||||
{
|
||||
public static IServiceCollection AddJwtToken(this IServiceCollection services, IConfiguration configuration)
|
||||
public static AuthenticationBuilder AddJwtToken(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var lifeTimeJwt = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_JWT"]!));
|
||||
|
||||
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"];
|
||||
@ -40,7 +43,7 @@ public static class JwtConfiguration
|
||||
SigningKey = jwtKey
|
||||
});
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
return services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
@ -60,7 +63,5 @@ public static class JwtConfiguration
|
||||
TokenDecryptionKey = new SymmetricSecurityKey(jwtDecrypt)
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -15,7 +18,7 @@ public static class LoggerConfiguration
|
||||
{
|
||||
public static IHostBuilder AddCustomSerilog(this IHostBuilder hostBuilder)
|
||||
{
|
||||
hostBuilder.UseSerilog((context, _, configuration) =>
|
||||
return hostBuilder.UseSerilog((context, _, configuration) =>
|
||||
{
|
||||
var generalConfig = context.Configuration.Get<GeneralConfig>()?.LogSettings;
|
||||
configuration
|
||||
@ -43,39 +46,57 @@ 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")));
|
||||
});
|
||||
|
||||
return hostBuilder;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseCustomSerilog(this IApplicationBuilder app)
|
||||
{
|
||||
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";
|
||||
|
||||
options.GetLevel = (_, elapsed, ex) => elapsed >= 2500 || ex != null
|
||||
? LogEventLevel.Warning
|
||||
: elapsed >= 1000
|
||||
? LogEventLevel.Information
|
||||
: LogEventLevel.Debug;
|
||||
options.GetLevel = (httpContext, elapsed, ex) =>
|
||||
{
|
||||
if (httpContext.Request.Path.StartsWithSegments("/health"))
|
||||
return LogEventLevel.Verbose;
|
||||
|
||||
return elapsed >= 2500 || ex != null
|
||||
? LogEventLevel.Warning
|
||||
: elapsed >= 1000
|
||||
? LogEventLevel.Information
|
||||
: LogEventLevel.Debug;
|
||||
};
|
||||
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
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("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() ?? string.Empty);
|
||||
};
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>();
|
||||
|
@ -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
|
||||
@ -51,15 +54,13 @@ public static class SwaggerConfiguration
|
||||
options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml"));
|
||||
});
|
||||
|
||||
services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
|
||||
|
||||
return services;
|
||||
return services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app, IServiceProvider services)
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
return app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.InjectStylesheet($"{UrlHelper.GetSubPath}css/swagger/SwaggerDark.css");
|
||||
var provider = services.GetService<IApiVersionDescriptionProvider>();
|
||||
@ -72,7 +73,5 @@ public static class SwaggerConfiguration
|
||||
options.RoutePrefix = UrlHelper.GetSubPathSwagger.Trim('/');
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
@ -2,4 +2,4 @@
|
||||
public interface ISaveSettings
|
||||
{
|
||||
void SaveSetting();
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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()
|
||||
};
|
||||
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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") }
|
||||
};
|
||||
|
@ -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)
|
||||
{
|
28
Endpoint/Configuration/SwaggerOptions/EnumSchemaFilter.cs
Normal file
28
Endpoint/Configuration/SwaggerOptions/EnumSchemaFilter.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
40
Endpoint/Configuration/SwaggerOptions/TagSchemeFilter.cs
Normal file
40
Endpoint/Configuration/SwaggerOptions/TagSchemeFilter.cs
Normal 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] });
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
11
Endpoint/Controllers/ConfigurationBaseController.cs
Normal file
11
Endpoint/Controllers/ConfigurationBaseController.cs
Normal 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;
|
@ -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")]
|
||||
@ -161,27 +213,39 @@ public class SetupController(
|
||||
}
|
||||
else if (Directory.GetDirectories(path).Length != 0 ||
|
||||
!Directory.GetFiles(path).Select(x => string.Equals(Path.GetFileName(x), "database.db3")).All(x => x))
|
||||
{
|
||||
throw new ControllerArgumentException("Such a folder exists. Enter a different name");
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(path, "database.db3");
|
||||
var connectionString = $"Data Source={filePath}";
|
||||
|
||||
//System.IO.File.Create(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]
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
219
Endpoint/Controllers/V1/Configuration/ScheduleController.cs
Normal file
219
Endpoint/Controllers/V1/Configuration/ScheduleController.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -1,11 +1,11 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Mirea.Api.DataAccess.Application.Cqrs.Faculty.Queries.GetFacultyDetails;
|
||||
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;
|
||||
|
||||
@ -23,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()
|
||||
{
|
||||
@ -35,34 +36,8 @@ public class FacultyController(IMediator mediator) : BaseController
|
||||
.Select(f => new FacultyResponse()
|
||||
{
|
||||
Id = f.Id,
|
||||
Name = f.Name,
|
||||
CampusId = f.CampusId
|
||||
Name = f.Name
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets details of a specific faculty by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">Faculty ID.</param>
|
||||
/// <returns>Details of the specified faculty.</returns>
|
||||
[HttpGet("{id:int}")]
|
||||
[BadRequestResponse]
|
||||
[NotFoundResponse]
|
||||
public async Task<ActionResult<FacultyDetailsResponse>> GetDetails(int id)
|
||||
{
|
||||
var result = await mediator.Send(new GetFacultyInfoQuery()
|
||||
{
|
||||
Id = id
|
||||
});
|
||||
|
||||
return Ok(new FacultyDetailsResponse()
|
||||
{
|
||||
Id = result.Id,
|
||||
Name = result.Name,
|
||||
CampusId = result.CampusId,
|
||||
CampusCode = result.CampusCode,
|
||||
CampusName = result.CampusName
|
||||
});
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
165
Endpoint/Controllers/V1/ImportController.cs
Normal file
165
Endpoint/Controllers/V1/ImportController.cs
Normal file
@ -0,0 +1,165 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mirea.Api.DataAccess.Application.Cqrs.Schedule.Queries.GetScheduleList;
|
||||
using Mirea.Api.Dto.Requests;
|
||||
using Mirea.Api.Endpoint.Configuration.Model;
|
||||
using OfficeOpenXml;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig> config) : BaseController
|
||||
{
|
||||
// todo: transfer data to storage
|
||||
private static string GetFaculty(char c) =>
|
||||
c switch
|
||||
{
|
||||
'У' => "ИТУ",
|
||||
'Б' => "ИКБ",
|
||||
'Х' => "ИТХТ",
|
||||
'Э' => "ИПТИП",
|
||||
'Т' => "ИПТИП",
|
||||
'Р' => "ИРИ",
|
||||
'К' => "ИИИ",
|
||||
'И' => "ИИТ",
|
||||
'П' => "ИИТ",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c), c, null)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Excel file based on a schedule filter
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing filter criteria.</param>
|
||||
/// <returns>Excel file</returns>
|
||||
[HttpPost("ImportToExcel")]
|
||||
[Produces("application/vnd.ms-excel")]
|
||||
public async Task<FileStreamResult> ImportToExcel([FromBody] ScheduleRequest request)
|
||||
{
|
||||
var result = (await mediator.Send(new GetScheduleListQuery
|
||||
{
|
||||
IsEven = request.IsEven,
|
||||
DisciplineIds = request.Disciplines,
|
||||
GroupIds = request.Groups,
|
||||
LectureHallIds = request.LectureHalls,
|
||||
ProfessorIds = request.Professors,
|
||||
LessonTypeIds = request.LessonType
|
||||
})).Schedules.ToList();
|
||||
|
||||
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
||||
using var package = new ExcelPackage();
|
||||
var worksheet = package.Workbook.Worksheets.Add("Расписание");
|
||||
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
worksheet.Cells[row, col++].Value = "День";
|
||||
worksheet.Cells[row, col++].Value = "Пара";
|
||||
worksheet.Cells[row, col++].Value = "Неделя";
|
||||
worksheet.Cells[row, col++].Value = "Время";
|
||||
worksheet.Cells[row, col++].Value = "Группа";
|
||||
worksheet.Cells[row, col++].Value = "Институт";
|
||||
worksheet.Cells[row, col++].Value = "Курс";
|
||||
worksheet.Cells[row, col++].Value = "Дисциплина";
|
||||
worksheet.Cells[row, col++].Value = "Преподаватель";
|
||||
worksheet.Cells[row, col++].Value = "Вид";
|
||||
worksheet.Cells[row, col++].Value = "Кампус";
|
||||
worksheet.Cells[row, col].Value = "Ауд.";
|
||||
|
||||
row++;
|
||||
col = 1;
|
||||
|
||||
var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod;
|
||||
|
||||
var ruCulture = new CultureInfo("ru-RU");
|
||||
|
||||
foreach (var dto in result.GroupBy(s => new
|
||||
{
|
||||
s.DayOfWeek,
|
||||
s.PairNumber,
|
||||
s.IsEven,
|
||||
s.DisciplineId,
|
||||
TypeOfOccupations = string.Join(',', s.TypeOfOccupations.OrderBy(x => x)),
|
||||
LectureHalls = string.Join(',', s.LectureHalls.OrderBy(x => x)),
|
||||
Campus = string.Join(',', s.Campus.OrderBy(x => x)),
|
||||
Professors = string.Join(',', s.Professors.OrderBy(x => x))
|
||||
})
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.DayOfWeek,
|
||||
g.Key.PairNumber,
|
||||
g.Key.IsEven,
|
||||
g.First().Discipline,
|
||||
g.First().LectureHalls,
|
||||
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
|
||||
})
|
||||
.ToList())
|
||||
{
|
||||
// День
|
||||
worksheet.Cells[row, col++].Value =
|
||||
$"{(int)dto.DayOfWeek} [{ruCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]";
|
||||
|
||||
// Пара
|
||||
worksheet.Cells[row, col++].Value = dto.PairNumber + " п";
|
||||
|
||||
// Неделя
|
||||
worksheet.Cells[row, col++].Value = $"[{(dto.IsEven ? 2 : 1)}] {(dto.IsEven ? "Четная" : "Нечетная")}";
|
||||
|
||||
// Время
|
||||
worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(ruCulture);
|
||||
|
||||
// Группа
|
||||
worksheet.Cells[row, col].Style.WrapText = true;
|
||||
worksheet.Cells[row, col++].Value = dto.Groups;
|
||||
|
||||
var groupTemplate = dto.Groups.Split('\n')[0];
|
||||
|
||||
// Институт
|
||||
worksheet.Cells[row, col++].Value = GetFaculty(groupTemplate[0]);
|
||||
// Курс
|
||||
worksheet.Cells[row, col++].Value = groupTemplate[2] == 'М' ?
|
||||
'М' :
|
||||
(24 - int.Parse(groupTemplate.Split(' ')[0].Split('-').TakeLast(1).ElementAt(0)) + 1).ToString();
|
||||
|
||||
var disciplineAdditional = string.Empty;
|
||||
|
||||
if (dto.IsExclude.HasValue && dto.Weeks != null && dto.Weeks.Any())
|
||||
disciplineAdditional += $"{(dto.IsExclude.Value ? "Кр. " : "")}{string.Join(", ", dto.Weeks.OrderBy(x => x))} н. ";
|
||||
|
||||
// Дисциплина
|
||||
worksheet.Cells[row, col++].Value = disciplineAdditional + dto.Discipline;
|
||||
|
||||
// Преподаватель
|
||||
worksheet.Cells[row, col++].Value = dto.Professors;
|
||||
// Вид
|
||||
worksheet.Cells[row, col++].Value = dto.TypeOfOccupations.FirstOrDefault();
|
||||
// Кампус
|
||||
worksheet.Cells[row, col++].Value = dto.Campus.FirstOrDefault()?.Replace("С-20", "С20").Replace("В-78", "В78");
|
||||
// Ауд.
|
||||
worksheet.Cells[row, col].Value = dto.LectureHalls;
|
||||
|
||||
col = 1;
|
||||
row++;
|
||||
}
|
||||
|
||||
worksheet.Cells[1, 1, 1, 12].AutoFilter = true;
|
||||
worksheet.Cells[worksheet.Dimension.Address].AutoFitColumns();
|
||||
|
||||
var stream = new MemoryStream();
|
||||
await package.SaveAsAsync(stream);
|
||||
stream.Position = 0;
|
||||
return File(stream, "application/vnd.ms-excel", "data.xlsx");
|
||||
}
|
||||
}
|
43
Endpoint/Controllers/V1/LessonTypeController.cs
Normal file
43
Endpoint/Controllers/V1/LessonTypeController.cs
Normal 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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -2,10 +2,13 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorDetails;
|
||||
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorDetailsBySearch;
|
||||
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;
|
||||
|
||||
@ -23,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()
|
||||
{
|
||||
@ -63,4 +67,35 @@ public class ProfessorController(IMediator mediator) : BaseController
|
||||
AltName = result.AltName
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves detailed information about professors based on their name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method searches for professors whose name matches the provided search term.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name of the professor to search for. Must be at least 4 characters long.</param>
|
||||
/// <returns>
|
||||
/// A list of <see cref="ProfessorResponse"/> objects containing the details of the matching professors.
|
||||
/// </returns>
|
||||
[HttpGet("{name:required}")]
|
||||
[BadRequestResponse]
|
||||
[NotFoundResponse]
|
||||
public async Task<ActionResult<List<ProfessorResponse>>> GetDetails([MinLength(4)] string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name) || name.Length < 4)
|
||||
throw new ControllerArgumentException($"The minimum number of characters is 4 (current: {name.Length}).");
|
||||
|
||||
var result = await mediator.Send(new GetProfessorInfoSearchQuery()
|
||||
{
|
||||
Name = name
|
||||
});
|
||||
|
||||
return Ok(result.Details.Select(x => new ProfessorResponse()
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
AltName = x.AltName
|
||||
}));
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -21,10 +22,18 @@ namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||
[CacheMaxAge(true)]
|
||||
public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConfig> config) : BaseController
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the start term for the schedule.
|
||||
/// </summary>
|
||||
/// <returns>The start term as a <see cref="DateOnly"/> value.</returns>
|
||||
[CacheMaxAge(1, 0)]
|
||||
[HttpGet("StartTerm")]
|
||||
public ActionResult<DateOnly> GetStartTerm() => config.Value.ScheduleSettings!.StartTerm;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the pair periods.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of pair periods, where the key is an integer identifier and the value is a <see cref="PairPeriodTime"/> object.</returns>
|
||||
[CacheMaxAge(1, 0)]
|
||||
[HttpGet("PairPeriod")]
|
||||
public ActionResult<Dictionary<int, PairPeriodTime>> GetPairPeriod() => config.Value.ScheduleSettings!.PairPeriod.ConvertToDto();
|
||||
@ -42,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
|
||||
@ -60,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();
|
||||
@ -96,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)]
|
||||
@ -105,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>
|
||||
@ -123,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)]
|
||||
@ -132,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>
|
||||
@ -150,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)]
|
||||
@ -159,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>
|
||||
@ -177,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)]
|
||||
@ -186,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
|
||||
});
|
||||
}
|
86
Endpoint/Controllers/V1/SecurityController.cs
Normal file
86
Endpoint/Controllers/V1/SecurityController.cs
Normal 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());
|
||||
}
|
@ -5,55 +5,65 @@
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Company>Winsomnia</Company>
|
||||
<Version>1.0.0-rc2</Version>
|
||||
<AssemblyVersion>1.0.2.2</AssemblyVersion>
|
||||
<FileVersion>1.0.2.2</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="Cronos" Version="0.8.4" />
|
||||
<PackageReference Include="EPPlus" Version="7.4.0" />
|
||||
<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.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<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.8.1" />
|
||||
<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.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.5" />
|
||||
<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>
|
||||
|
@ -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;
|
||||
@ -10,6 +12,7 @@ using Mirea.Api.DataAccess.Persistence;
|
||||
using Mirea.Api.DataAccess.Persistence.Common;
|
||||
using Mirea.Api.Endpoint.Common.Interfaces;
|
||||
using Mirea.Api.Endpoint.Common.Services;
|
||||
using Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
|
||||
using Mirea.Api.Endpoint.Configuration.Core.Middleware;
|
||||
using Mirea.Api.Endpoint.Configuration.Core.Startup;
|
||||
using Mirea.Api.Endpoint.Configuration.Model;
|
||||
@ -23,7 +26,7 @@ namespace Mirea.Api.Endpoint;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static IServiceCollection AddDatabase(IServiceCollection services, IConfiguration configuration)
|
||||
public static IServiceCollection AddDatabase(IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthCheckBuilder = null)
|
||||
{
|
||||
var dbSettings = configuration.Get<GeneralConfig>()?.DbSettings;
|
||||
services.AddApplication();
|
||||
@ -31,6 +34,10 @@ public class Program
|
||||
dbSettings?.DatabaseProvider ?? DatabaseProvider.Sqlite,
|
||||
dbSettings?.ConnectionStringSql ?? string.Empty);
|
||||
|
||||
healthCheckBuilder?.AddDatabaseHealthCheck(
|
||||
dbSettings?.DatabaseProvider ?? DatabaseProvider.Sqlite,
|
||||
dbSettings?.ConnectionStringSql ?? string.Empty);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@ -40,22 +47,32 @@ public class Program
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddConfiguration(EnvironmentConfiguration.GetEnvironment());
|
||||
|
||||
var healthCheckBuilder = builder.Services.AddHealthChecks();
|
||||
builder.Configuration.AddJsonFile(GeneralConfig.FilePath, optional: true, reloadOnChange: true);
|
||||
builder.Services.Configure<GeneralConfig>(builder.Configuration);
|
||||
healthCheckBuilder.AddFile(x => x.AddFile(GeneralConfig.FilePath), name: nameof(GeneralConfig));
|
||||
|
||||
builder.Configuration.AddJsonFile(Admin.FilePath, optional: true, reloadOnChange: true);
|
||||
builder.Services.Configure<Admin>(builder.Configuration);
|
||||
healthCheckBuilder.AddFile(x => x.AddFile(Admin.FilePath), name: nameof(Admin));
|
||||
|
||||
builder.Host.AddCustomSerilog();
|
||||
AddDatabase(builder.Services, builder.Configuration);
|
||||
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>();
|
||||
builder.Services.AddSingleton<ISetupToken, SetupTokenService>();
|
||||
|
||||
builder.Services.AddHostedService<ScheduleSyncService>();
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddCustomRedis(builder.Configuration);
|
||||
builder.Services.AddCustomRedis(builder.Configuration, healthCheckBuilder);
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@ -64,9 +81,7 @@ public class Program
|
||||
policy.AllowAnyMethod();
|
||||
policy.AllowAnyHeader();
|
||||
policy.AllowCredentials();
|
||||
#if DEBUG
|
||||
policy.WithOrigins("http://localhost:4200");
|
||||
#endif
|
||||
policy.SetIsOriginAllowed(_ => true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -108,6 +123,7 @@ public class Program
|
||||
app.UseStaticFiles(UrlHelper.GetSubPath.TrimEnd('/'));
|
||||
app.UseCors("AllowAll");
|
||||
app.UseCustomSerilog();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
|
42
Endpoint/Sync/Common/DataRepository.cs
Normal file
42
Endpoint/Sync/Common/DataRepository.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Sync.Common;
|
||||
|
||||
internal class DataRepository<T> where T : class
|
||||
{
|
||||
private readonly ConcurrentBag<T> _data = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IEnumerable<T> GetAll() => _data.ToList();
|
||||
|
||||
public DataRepository(List<T> data)
|
||||
{
|
||||
foreach (var d in data)
|
||||
_data.Add(d);
|
||||
}
|
||||
|
||||
public T? Get(Func<T, bool> predicate)
|
||||
{
|
||||
var entity = _data.FirstOrDefault(predicate);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public T Create(Func<T> createEntity)
|
||||
{
|
||||
var entity = createEntity();
|
||||
_data.Add(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public T GetOrCreate(Func<T, bool> predicate, Func<T> createEntity)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entity = Get(predicate);
|
||||
return entity ?? Create(createEntity);
|
||||
}
|
||||
}
|
||||
}
|
518
Endpoint/Sync/ScheduleSynchronizer.cs
Normal file
518
Endpoint/Sync/ScheduleSynchronizer.cs
Normal file
@ -0,0 +1,518 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mirea.Api.DataAccess.Domain.Schedule;
|
||||
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;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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)
|
||||
{
|
||||
private readonly DataRepository<Campus> _campuses = new([.. dbContext.Campuses]);
|
||||
private readonly DataRepository<Discipline> _disciplines = new([.. dbContext.Disciplines]);
|
||||
private readonly DataRepository<Faculty> _faculties = new([.. dbContext.Faculties]);
|
||||
private readonly DataRepository<Group> _groups = new([.. dbContext.Groups]);
|
||||
private readonly DataRepository<LectureHall> _lectureHalls = new([.. dbContext.LectureHalls]);
|
||||
private readonly DataRepository<Lesson> _lessons = new([]);
|
||||
private readonly DataRepository<LessonAssociation> _lessonAssociation = new([]);
|
||||
private readonly DataRepository<Professor> _professors = new([.. dbContext.Professors]);
|
||||
private readonly DataRepository<TypeOfOccupation> _typeOfOccupations = new([.. dbContext.TypeOfOccupations]);
|
||||
private readonly DataRepository<SpecificWeek> _specificWeeks = new([]);
|
||||
|
||||
// todo: transfer data to storage
|
||||
private static string GetFaculty(char c) =>
|
||||
c switch
|
||||
{
|
||||
'У' => "ИТУ",
|
||||
'Б' => "ИКБ",
|
||||
'Х' => "ИТХТ",
|
||||
'Э' => "ИПТИП",
|
||||
'Т' => "ИПТИП",
|
||||
'Р' => "ИРИ",
|
||||
'К' => "ИИИ",
|
||||
'И' => "ИИТ",
|
||||
'П' => "ИИТ",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c), c, null)
|
||||
};
|
||||
|
||||
private void ParallelSync(GroupResult groupInfo)
|
||||
{
|
||||
var facultyName = GetFaculty(groupInfo.Group[0]);
|
||||
|
||||
var faculty = _faculties.GetOrCreate(
|
||||
f => f.Name.Equals(facultyName, StringComparison.OrdinalIgnoreCase),
|
||||
() => new Faculty
|
||||
{
|
||||
Name = facultyName
|
||||
});
|
||||
|
||||
var groupName = OnlyGroupName().Match(groupInfo.Group.ToUpper()).Value;
|
||||
|
||||
var group = _groups.GetOrCreate(
|
||||
g => g.Name.Equals(groupName, StringComparison.OrdinalIgnoreCase),
|
||||
() => new Group
|
||||
{
|
||||
Name = groupName,
|
||||
Faculty = faculty
|
||||
});
|
||||
|
||||
var typeOfOccupation = _typeOfOccupations.GetOrCreate(
|
||||
t => t.ShortName.Equals(groupInfo.TypeOfOccupation.Trim(), StringComparison.OrdinalIgnoreCase),
|
||||
() => new TypeOfOccupation
|
||||
{
|
||||
ShortName = groupInfo.TypeOfOccupation.ToUpper()
|
||||
});
|
||||
|
||||
List<Professor>? professor = [];
|
||||
if (groupInfo.Professor != null)
|
||||
{
|
||||
foreach (var prof in groupInfo.Professor)
|
||||
{
|
||||
var professorParts = prof.Split(' ').ToList();
|
||||
|
||||
string? altName = null;
|
||||
|
||||
if (professorParts is { Count: >= 2 })
|
||||
{
|
||||
altName = professorParts.ElementAtOrDefault(0);
|
||||
|
||||
if (professorParts.ElementAtOrDefault(1) != null)
|
||||
altName += $" {professorParts.ElementAtOrDefault(1)?[0]}.";
|
||||
|
||||
if (professorParts.ElementAtOrDefault(2) != null)
|
||||
altName += $"{professorParts.ElementAtOrDefault(2)?[0]}.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(altName))
|
||||
continue;
|
||||
|
||||
var profDb = _professors.GetOrCreate(x =>
|
||||
(x.AltName == null || x.AltName.Equals(prof, StringComparison.OrdinalIgnoreCase)) &&
|
||||
x.Name.Equals(altName, StringComparison.OrdinalIgnoreCase),
|
||||
() => new Professor
|
||||
{
|
||||
AltName = prof,
|
||||
Name = altName
|
||||
});
|
||||
|
||||
professor.Add(profDb);
|
||||
}
|
||||
}
|
||||
else
|
||||
professor = null;
|
||||
|
||||
List<LectureHall>? hall = null;
|
||||
List<Campus>? campuses;
|
||||
if (groupInfo.Campuses != null && groupInfo.Campuses.Length != 0)
|
||||
{
|
||||
hall = [];
|
||||
campuses = [];
|
||||
for (var i = 0; i < groupInfo.Campuses.Length; i++)
|
||||
{
|
||||
var campus = groupInfo.Campuses[i];
|
||||
campuses.Add(_campuses.GetOrCreate(
|
||||
c => c.CodeName.Equals(campus, StringComparison.OrdinalIgnoreCase),
|
||||
() => new Campus
|
||||
{
|
||||
CodeName = campus.ToUpper()
|
||||
}));
|
||||
if (groupInfo.LectureHalls == null || groupInfo.LectureHalls.Length <= i)
|
||||
continue;
|
||||
var lectureHall = groupInfo.LectureHalls[i];
|
||||
|
||||
hall.Add(_lectureHalls.GetOrCreate(l =>
|
||||
l.Name.Equals(lectureHall, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(l.Campus?.CodeName, campuses[^1].CodeName, StringComparison.CurrentCultureIgnoreCase),
|
||||
() => new LectureHall
|
||||
{
|
||||
Name = lectureHall,
|
||||
Campus = campuses[^1]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
var discipline = _disciplines.GetOrCreate(
|
||||
d => d.Name.Equals(groupInfo.Discipline, StringComparison.OrdinalIgnoreCase),
|
||||
() => new Discipline
|
||||
{
|
||||
Name = groupInfo.Discipline
|
||||
});
|
||||
|
||||
Lesson lesson = _lessons.GetOrCreate(l =>
|
||||
l.IsEven == groupInfo.IsEven &&
|
||||
l.DayOfWeek == groupInfo.Day &&
|
||||
l.PairNumber == groupInfo.Pair &&
|
||||
l.Discipline?.Name == discipline.Name &&
|
||||
l.Group?.Name == group.Name,
|
||||
() =>
|
||||
{
|
||||
var lesson = new Lesson
|
||||
{
|
||||
IsEven = groupInfo.IsEven,
|
||||
DayOfWeek = groupInfo.Day,
|
||||
PairNumber = groupInfo.Pair,
|
||||
Discipline = discipline,
|
||||
Group = group,
|
||||
IsExcludedWeeks = groupInfo.IsExclude
|
||||
};
|
||||
|
||||
if (groupInfo.SpecialWeek == null)
|
||||
return lesson;
|
||||
|
||||
foreach (var week in groupInfo.SpecialWeek)
|
||||
_specificWeeks.Create(() => new SpecificWeek
|
||||
{
|
||||
Lesson = lesson,
|
||||
WeekNumber = week
|
||||
});
|
||||
|
||||
return lesson;
|
||||
});
|
||||
|
||||
var maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
|
||||
|
||||
for (var i = 0; i < maxValue; i++)
|
||||
{
|
||||
var prof = professor?.ElementAtOrDefault(i);
|
||||
var lectureHall = hall?.ElementAtOrDefault(i);
|
||||
_lessonAssociation.Create(() => new LessonAssociation
|
||||
{
|
||||
Professor = prof,
|
||||
Lesson = lesson,
|
||||
LectureHall = lectureHall,
|
||||
TypeOfOccupation = typeOfOccupation
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveChanges(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var group in _groups.GetAll())
|
||||
{
|
||||
var existingGroup = await dbContext.Groups.FirstOrDefaultAsync(g => g.Id == group.Id, cancellationToken);
|
||||
if (existingGroup != null)
|
||||
dbContext.Remove(existingGroup);
|
||||
}
|
||||
|
||||
await dbContext.Disciplines.BulkSynchronizeAsync(_disciplines.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
await dbContext.Professors.BulkSynchronizeAsync(_professors.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
await dbContext.TypeOfOccupations.BulkSynchronizeAsync(_typeOfOccupations.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
|
||||
await dbContext.Faculties.BulkSynchronizeAsync(_faculties.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
|
||||
await dbContext.Campuses.BulkSynchronizeAsync(_campuses.GetAll(), bulkOperation => bulkOperation.BatchSize = 10, cancellationToken);
|
||||
await dbContext.LectureHalls.BulkSynchronizeAsync(_lectureHalls.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
await dbContext.Groups.BulkSynchronizeAsync(_groups.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
|
||||
await dbContext.Lessons.BulkSynchronizeAsync(_lessons.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
await dbContext.SpecificWeeks.BulkSynchronizeAsync(_specificWeeks.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task Sync(Func<CancellationToken, Task<List<GroupResult>>> parseDataAsync, CancellationToken cancellationToken)
|
||||
{
|
||||
Stopwatch watch = new();
|
||||
watch.Start();
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Start parsing schedule");
|
||||
var data = await parseDataAsync(cancellationToken);
|
||||
|
||||
watch.Stop();
|
||||
var parsingTime = watch.ElapsedMilliseconds;
|
||||
watch.Restart();
|
||||
|
||||
ParallelOptions options = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount
|
||||
};
|
||||
|
||||
logger.LogDebug("Start mapping parsed data");
|
||||
Parallel.ForEach(data, options, ParallelSync);
|
||||
|
||||
watch.Stop();
|
||||
var mappingTime = watch.ElapsedMilliseconds;
|
||||
watch.Restart();
|
||||
|
||||
maintenanceMode.EnableMaintenanceMode();
|
||||
|
||||
logger.LogDebug("Start saving changing");
|
||||
await SaveChanges(cancellationToken);
|
||||
|
||||
maintenanceMode.DisableMaintenanceMode();
|
||||
|
||||
watch.Stop();
|
||||
|
||||
logger.LogInformation("Parsing time: {ParsingTime}ms Mapping time: {MappingTime}ms Saving time: {SavingTime}ms Total time: {TotalTime}ms",
|
||||
parsingTime, mappingTime, watch.ElapsedMilliseconds, parsingTime + mappingTime + watch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred during synchronization.");
|
||||
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();
|
||||
}
|
149
README.md
149
README.md
@ -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 | ❌ | 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
|
||||
|
||||
@ -170,7 +255,7 @@ Install git in advance or clone the repository in another way.
|
||||
|
||||
```bash
|
||||
git clone https://git.winsomnia.net/Winsomnia/MireaBackend.git \
|
||||
cd DoctorTelegramBot
|
||||
cd MireaBackend
|
||||
```
|
||||
|
||||
### Build Self Release
|
||||
|
8
Security/Common/CookieNames.cs
Normal file
8
Security/Common/CookieNames.cs
Normal 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";
|
||||
}
|
@ -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; }
|
||||
}
|
27
Security/Common/Domain/Caching/AuthToken.cs
Normal file
27
Security/Common/Domain/Caching/AuthToken.cs
Normal 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; }
|
||||
}
|
24
Security/Common/Domain/Caching/FirstAuthToken.cs
Normal file
24
Security/Common/Domain/Caching/FirstAuthToken.cs
Normal 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; }
|
||||
}
|
12
Security/Common/Domain/Caching/OAuthUserExtension.cs
Normal file
12
Security/Common/Domain/Caching/OAuthUserExtension.cs
Normal 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; }
|
||||
}
|
7
Security/Common/Domain/OAuthPayload.cs
Normal file
7
Security/Common/Domain/OAuthPayload.cs
Normal 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; }
|
||||
}
|
8
Security/Common/Domain/OAuthProvider.cs
Normal file
8
Security/Common/Domain/OAuthProvider.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public enum OAuthProvider
|
||||
{
|
||||
Google,
|
||||
Yandex,
|
||||
MailRu
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user