Release v1.0.0 #16

Merged
Wesser merged 492 commits from release/v1.0.0 into master 2025-02-12 09:19:32 +03:00
301 changed files with 16136 additions and 103 deletions

278
.editorconfig Normal file
View File

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

163
.env Normal file
View File

@ -0,0 +1,163 @@
# The .env configuration file
# Please DO NOT share this file, it contains confidential data.
# All variables are specified according to this rule:
# DESCRIPTION - information about what the variable is responsible for
# TYPE - the type of the variable (string, boolean, etc.)
# Any additional information
# SOME_ENV_CODE=data - default data. If specified, then the variable is optional
# General
# The path to save the data
# string
# (optional)
# Saving logs (if the full path is not specified),
# databases (if Sqlite) and other data that should be saved in a place other than the place where the program is launched.
# REQUIRED if the application is inside the container
# If you want to change this value, you need to change the values in Settings.json and move the file itself to the desired location.
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
# string
# (optional)
SWAGGER_SUB_PATH=swagger
# Internal port configuration
# integer
# (optional)
# Specify the internal port on which the server will listen.
INTERNAL_PORT=8080
# Security
# JWT signature token
# string (UTF8)
# This token will be used to create and verify the signature of JWT tokens.
# The token must be equal to 64 characters
SECURITY_SIGNING_TOKEN=
# Token for JWT encryption
# string (UTF8)
# This token will be used to encrypt and decrypt JWT tokens.
# The token must be equal to 32 characters
SECURITY_ENCRYPTION_TOKEN=
# Time in minutes, which indicates after which time the Refresh Token will become invalid
# integer
# The token indicates how long after the user is inactive, he will need to log in again
SECURITY_LIFE_TIME_RT=1440
# The time in a minute, which indicates that this is exactly what it takes to become a non-state
# integer
# Do not specify a time that is too long or too short. Optimally 5 > x > 60
SECURITY_LIFE_TIME_JWT=15
# Time in minutes, which indicates after which time the token of the first factor will become invalid
# integer
# Do not specify a short time. The user must be able to log in using the second factor
SECURITY_LIFE_TIME_1_FA=15
# An identifier that points to the server that created the token
# string
SECURITY_JWT_ISSUER=
# ID of the audience for which the token is intended
# string
SECURITY_JWT_AUDIENCE=
### Hashing
# In order to set up hashing correctly, you need to start from the security requirements
# You can use the settings that were used in https://github.com/P-H-C/phc-winner-argon2
# These parameters have a STRONG impact on performance
# When testing the system, these values were used:
# 10 <= SECURITY_HASH_ITERATION <= 25 iterations
# 16384 <= SECURITY_HASH_MEMORY <= 32768 KB
# 4 <= SECURITY_HASH_PARALLELISM <= 8 lines
# If we take all the large values, it will take a little more than 1 second to get the hash. If this time is critical, reduce the parameters
# The number of iterations used to hash passwords in the Argon2 algorithm
# integer
# This parameter determines the number of iterations that the Argon2 algorithm goes through when hashing passwords.
# Increasing this value can improve security by increasing the time it takes to calculate the password hash.
# The average number of iterations to increase the security level should be set to at least 10.
SECURITY_HASH_ITERATION=
# The amount of memory used to hash passwords in the Argon2 algorithm
# integer
# 65536
# This parameter determines the number of kilobytes of memory that will be used for the password hashing process.
# Increasing this value may increase security, but it may also require more system resources.
SECURITY_HASH_MEMORY=
# Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash
# integer
# This value affects the hash itself, but can be changed to achieve an ideal execution time, taking into account the processor and the number of cores.
SECURITY_HASH_PARALLELISM=
# The size of the output hash generated by the password hashing algorithm
# integer
SECURITY_HASH_SIZE=32
# Additional protection for Argon2
# string (BASE64)
# (optional)
# We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token
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
### 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=

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

@ -0,0 +1,30 @@
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
sonarBeginArguments: /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml" -d:sonar.cs.vstest.reportsPaths="**/TestResults/*.trx"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

97
.github/workflows/release-version.yml vendored Normal file
View File

@ -0,0 +1,97 @@
name: Build and Deploy Docker Container
on:
push:
branches:
[master]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
run: |
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
id: ssh-agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy to Server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
DOCKER_IMAGE: ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
PATH_TO_SAVE: /data
SECURITY_SIGNING_TOKEN: ${{ secrets.SECURITY_SIGNING_TOKEN }}
SECURITY_ENCRYPTION_TOKEN: ${{ secrets.SECURITY_ENCRYPTION_TOKEN }}
SECURITY_LIFE_TIME_RT: ${{ secrets.SECURITY_LIFE_TIME_RT }}
SECURITY_LIFE_TIME_JWT: ${{ secrets.SECURITY_LIFE_TIME_JWT }}
SECURITY_LIFE_TIME_1_FA: ${{ secrets.SECURITY_LIFE_TIME_1_FA }}
SECURITY_JWT_ISSUER: ${{ secrets.SECURITY_JWT_ISSUER }}
SECURITY_JWT_AUDIENCE: ${{ secrets.SECURITY_JWT_AUDIENCE }}
SECURITY_HASH_ITERATION: ${{ secrets.SECURITY_HASH_ITERATION }}
SECURITY_HASH_MEMORY: ${{ secrets.SECURITY_HASH_MEMORY }}
SECURITY_HASH_PARALLELISM: ${{ secrets.SECURITY_HASH_PARALLELISM }}
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 "
docker pull $DOCKER_IMAGE &&
docker stop mirea-backend || true &&
docker rm mirea-backend || true &&
docker run -d --name mirea-backend -p 8085:8080 \
--restart=on-failure:10 \
-v mirea-data:/data \
-e PATH_TO_SAVE=$PATH_TO_SAVE \
-e SECURITY_SIGNING_TOKEN=$SECURITY_SIGNING_TOKEN \
-e SECURITY_ENCRYPTION_TOKEN=$SECURITY_ENCRYPTION_TOKEN \
-e SECURITY_LIFE_TIME_RT=$SECURITY_LIFE_TIME_RT \
-e SECURITY_LIFE_TIME_JWT=$SECURITY_LIFE_TIME_JWT \
-e SECURITY_LIFE_TIME_1_FA=$SECURITY_LIFE_TIME_1_FA \
-e SECURITY_JWT_ISSUER=$SECURITY_JWT_ISSUER \
-e SECURITY_JWT_AUDIENCE=$SECURITY_JWT_AUDIENCE \
-e SECURITY_HASH_ITERATION=$SECURITY_HASH_ITERATION \
-e SECURITY_HASH_MEMORY=$SECURITY_HASH_MEMORY \
-e SECURITY_HASH_PARALLELISM=$SECURITY_HASH_PARALLELISM \
-e SECURITY_HASH_SIZE=$SECURITY_HASH_SIZE \
-e SECURITY_HASH_TOKEN=$SECURITY_HASH_TOKEN \
-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
"
- name: Remove all keys from ssh-agent
run: ssh-add -D

4
.gitignore vendored
View File

@ -360,4 +360,6 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
/ApiDto/ApiDtoDocs.xml
/Endpoint/docs.xml

42
ApiDto/ApiDto.csproj Normal file
View File

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Dto</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>ApiDtoDocs.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Update="ApiDtoDocs.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup>
<CopyAllFilesToSingleFolderForPackageDependsOn>
CopyXmlDocuments;
$(CopyAllFilesToSingleFolderForPackageDependsOn);
</CopyAllFilesToSingleFolderForPackageDependsOn>
<CopyAllFilesToSingleFolderForMsdeployDependsOn>
CopyXmlDocuments;
$(CopyAllFilesToSingleFolderForMsdeployDependsOn);
</CopyAllFilesToSingleFolderForMsdeployDependsOn>
</PropertyGroup>
<Target Name="CopyXmlDocuments">
<ItemGroup>
<XmlDocuments Include="$(OutDir)*.xml" />
<FilesForPackagingFromProject Include="%(XmlDocuments.Identity)">
<DestinationRelativePath>bin\%(RecursiveDir)%(Filename)%(Extension)</DestinationRelativePath>
</FilesForPackagingFromProject>
</ItemGroup>
</Target>
</Project>

View File

@ -0,0 +1,12 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// An enumeration that indicates which role the user belongs to
/// </summary>
public enum AuthRoles
{
/// <summary>
/// Administrator
/// </summary>
Admin
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a pair of time periods.
/// </summary>
public class PairPeriodTime
{
/// <summary>
/// Gets or sets the start time of the period.
/// </summary>
[Required]
public TimeOnly Start { get; set; }
/// <summary>
/// Gets or sets the end time of the period.
/// </summary>
[Required]
public TimeOnly End { get; set; }
}

View File

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

View File

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

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure cache settings.
/// </summary>
public class CacheRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure the database connection settings.
/// </summary>
public class DatabaseRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets the database name.
/// </summary>
[Required]
public required string Database { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public required string User { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
[Required]
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure email settings.
/// </summary>
public class EmailRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the email address from which emails will be sent.
/// </summary>
[Required]
public required string From { get; set; }
/// <summary>
/// Gets or sets the password for the email account.
/// </summary>
[Required]
public required string Password { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
[Required]
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public required string User { get; set; }
}

View File

@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure logging settings.
/// </summary>
public class LoggingRequest
{
/// <summary>
/// Gets or sets a value indicating whether logging to file is enabled.
/// </summary>
[Required]
public bool EnableLogToFile { get; set; }
/// <summary>
/// Gets or sets the log file name.
/// </summary>
public string? LogFileName { get; set; }
/// <summary>
/// Gets or sets the log file path.
/// </summary>
public string? LogFilePath { get; set; }
/// <summary>
/// Gets or sets the API key for integrating with Seq, a log aggregation service.
/// If provided, logs will be sent to a Seq server using this API key.
/// </summary>
public string? ApiKeySeq { get; set; }
/// <summary>
/// Gets or sets the server URL for the Seq logging service.
/// This property specifies the Seq server endpoint to which logs will be sent.
/// If <see cref="ApiKeySeq"/> is provided, logs will be sent to this server.
/// </summary>
public string? ApiServerSeq { get; set; }
}

View File

@ -0,0 +1,21 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure the schedule settings.
/// </summary>
public class ScheduleConfigurationRequest
{
/// <summary>
/// Gets or sets the cron expression for updating the schedule.
/// </summary>
public string? CronUpdateSchedule { get; set; }
/// <summary>
/// Gets or sets the start date of the term.
/// </summary>
[Required]
public DateOnly StartTerm { get; set; }
}

View File

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Request model for creating a user.
/// </summary>
public class CreateUserRequest
{
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
[Required]
[EmailAddress]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
[Required]
[MinLength(2)]
public required string Username { get; set; }
/// <summary>
/// Gets or sets the password of the user.
/// </summary>
[Required]
[MinLength(2)]
public required string Password { get; set; }
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Request to receive protected content
/// </summary>
public class LoginRequest
{
/// <summary>
/// Login or Email to identify the client.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// The client's password.
/// </summary>
[Required]
public required string Password { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Represents a request object for retrieving schedules based on various filters.
/// </summary>
public class ScheduleRequest
{
/// <summary>
/// Gets or sets an array of group IDs.
/// </summary>
public int[]? Groups { get; set; } = null;
/// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary>
public bool? IsEven { get; set; } = null;
/// <summary>
/// Gets or sets an array of discipline IDs.
/// </summary>
public int[]? Disciplines { get; set; } = null;
/// <summary>
/// Gets or sets an array of professor IDs.
/// </summary>
public int[]? Professors { get; set; } = null;
/// <summary>
/// Gets or sets an array of lecture hall IDs.
/// </summary>
public int[]? LectureHalls { get; set; } = null;
/// <summary>
/// Gets or sets an array of lesson type IDs.
/// </summary>
public int[]? LessonType { get; set; } = null;
}

View File

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

View File

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

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a campus.
/// </summary>
public class CampusBasicInfoResponse
{
/// <summary>
/// Gets or sets the unique identifier of the campus.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the code name of the campus.
/// </summary>
[Required]
public required string CodeName { get; set; }
/// <summary>
/// Gets or sets the full name of the campus (optional).
/// </summary>
public string? FullName { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents detailed information about a campus.
/// </summary>
public class CampusDetailsResponse
{
/// <summary>
/// Gets or sets the unique identifier of the campus.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the code name of the campus.
/// </summary>
[Required]
public required string CodeName { get; set; }
/// <summary>
/// Gets or sets the full name of the campus (optional).
/// </summary>
public string? FullName { get; set; }
/// <summary>
/// Gets or sets the address of the campus (optional).
/// </summary>
public string? Address { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a faculty.
/// </summary>
public class FacultyResponse
{
/// <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; }
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents detailed information about a group.
/// </summary>
public class GroupDetailsResponse
{
/// <summary>
/// Gets or sets the unique identifier of the group.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the group.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the course number of the group.
/// </summary>
[Required]
public int CourseNumber { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the faculty to which the group belongs (optional).
/// </summary>
public int? FacultyId { get; set; }
/// <summary>
/// Gets or sets the name of the faculty to which the group belongs (optional).
/// </summary>
public string? FacultyName { get; set; }
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a group.
/// </summary>
public class GroupResponse
{
/// <summary>
/// Gets or sets the unique identifier of the group.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the group.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the course number of the group.
/// </summary>
[Required]
public int CourseNumber { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the faculty to which the group belongs (optional).
/// </summary>
public int? FacultyId { get; set; }
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the detailed response model for a lecture hall.
/// </summary>
public class LectureHallDetailsResponse
{
/// <summary>
/// Gets or sets the ID of the lecture hall.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lecture hall.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the ID of the campus to which the lecture hall belongs.
/// </summary>
[Required]
public int CampusId { get; set; }
/// <summary>
/// Gets or sets the name of the campus.
/// </summary>
public string? CampusName { get; set; }
/// <summary>
/// Gets or sets the code of the campus.
/// </summary>
public string? CampusCode { get; set; }
}

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response model for a lecture hall.
/// </summary>
public class LectureHallResponse
{
/// <summary>
/// Gets or sets the ID of the lecture hall.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lecture hall.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the ID of the campus to which the lecture hall belongs.
/// </summary>
[Required]
public int CampusId { get; set; }
}

View File

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

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a professor.
/// </summary>
public class ProfessorResponse
{
/// <summary>
/// Gets or sets the unique identifier of the professor.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the professor.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the alternate name of the professor (optional).
/// </summary>
public string? AltName { get; set; }
}

View File

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents a response object containing schedule information.
/// </summary>
public class ScheduleResponse
{
/// <summary>
/// Gets or sets the day of the week for the schedule entry.
/// </summary>
[Required]
public DayOfWeek DayOfWeek { get; set; }
/// <summary>
/// Gets or sets the pair number for the schedule entry.
/// </summary>
[Required]
public int PairNumber { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the pair is on an even week.
/// </summary>
[Required]
public bool IsEven { get; set; }
/// <summary>
/// Gets or sets the name of the discipline for the schedule entry.
/// </summary>
[Required]
public required string Discipline { get; set; }
/// <summary>
/// Gets or sets the ID of the discipline for the schedule entry.
/// </summary>
[Required]
public required int DisciplineId { get; set; }
/// <summary>
/// Gets or sets exclude or include weeks for a specific discipline.
/// </summary>
/// <remarks>
/// If is <see langword="true"/>, then the values in <see cref="Weeks"/> show the weeks when there will be no discipline.
/// <br/>
/// If is <see langword="false"/>, then the values in <see cref="Weeks"/> indicate the weeks during which a particular discipline will be studied.
/// <br/>
/// If is <see langword="null"/>, then there are no specific <see cref="Weeks"/>
/// </remarks>
///
public bool? IsExcludedWeeks { get; set; }
/// <summary>
/// The week numbers required for the correct display of the schedule.
/// <br/>
/// Whether there will be <see cref="Discipline"/> during the week or not depends on the <see cref="IsExcludedWeeks"/> property.
/// </summary>
/// <remarks>
/// To get the current week's number, use other queries.
/// </remarks>
public IEnumerable<int>? Weeks { get; set; }
/// <summary>
/// Gets or sets the type of occupation for the schedule entry.
/// </summary>
[Required]
public required IEnumerable<string> TypeOfOccupations { get; set; }
/// <summary>
/// Gets or sets the name of the group for the schedule entry.
/// </summary>
[Required]
public required string Group { get; set; }
/// <summary>
/// Gets or sets the ID of the group for the schedule entry.
/// </summary>
[Required]
public required int GroupId { get; set; }
/// <summary>
/// Gets or sets the names of the lecture halls for the schedule entry.
/// </summary>
public required IEnumerable<string?> LectureHalls { get; set; }
/// <summary>
/// Gets or sets the IDs of the lecture halls for the schedule entry.
/// </summary>
public required IEnumerable<int?> LectureHallsId { get; set; }
/// <summary>
/// Gets or sets the names of the professors for the schedule entry.
/// </summary>
public required IEnumerable<string?> Professors { get; set; }
/// <summary>
/// Gets or sets the IDs of the professors for the schedule entry.
/// </summary>
public required IEnumerable<int?> ProfessorsId { get; set; }
/// <summary>
/// Gets or sets the names of the campuses for the schedule entry.
/// </summary>
public required IEnumerable<string?> Campus { get; set; }
/// <summary>
/// Gets or sets the IDs of the campuses for the schedule entry.
/// </summary>
public required IEnumerable<int?> CampusId { get; set; }
/// <summary>
/// Gets or sets the links to online meetings for the schedule entry.
/// </summary>
public required IEnumerable<string?> LinkToMeet { get; set; }
}

View File

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

View File

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

View File

@ -3,18 +3,49 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.8.34330.188
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{C27FB5CD-6A70-4FB2-847A-847B34806902}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Endpoint", "Endpoint\Endpoint.csproj", "{F3A1D12E-F5B2-4339-9966-DBF869E78357}"
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
.github\workflows\code-analyze.yaml = .github\workflows\code-analyze.yaml
Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt
README.md = README.md
.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}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Security", "Security\Security.csproj", "{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SqlData", "SqlData", "{7E7A63CD-547B-4FB4-A383-EB75298020A1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "SqlData\Domain\Domain.csproj", "{3BFD6180-7CA7-4E85-A379-225B872439A1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "SqlData\Application\Application.csproj", "{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Persistence", "SqlData\Persistence\Persistence.csproj", "{48C9998C-ECE2-407F-835F-1A7255A5C99E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrations", "Migrations", "{79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqliteMigrations", "SqlData\Migrations\SqliteMigrations\SqliteMigrations.csproj", "{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MysqlMigrations", "SqlData\Migrations\MysqlMigrations\MysqlMigrations.csproj", "{5861915B-9574-4D5D-872F-D54A09651697}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PsqlMigrations", "SqlData\Migrations\PsqlMigrations\PsqlMigrations.csproj", "{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection
EndProject
Global
@ -23,18 +54,55 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Release|Any CPU.Build.0 = Release|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.Build.0 = Release|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.Build.0 = Release|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.Build.0 = Release|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Release|Any CPU.Build.0 = Release|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Release|Any CPU.Build.0 = Release|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Release|Any CPU.Build.0 = Release|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Release|Any CPU.Build.0 = Release|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Release|Any CPU.Build.0 = Release|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3BFD6180-7CA7-4E85-A379-225B872439A1} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
{5861915B-9574-4D5D-872F-D54A09651697} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E80A1224-87F5-4FEB-82AE-89006BE98B12}
EndGlobalSection

View File

@ -1,25 +1,32 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
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
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Backend.csproj", "."]
RUN dotnet restore "./././Backend.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "./Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
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 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
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Backend.dll"]
COPY --from=build /app .
RUN find . -name "*.pdb" -type f -delete
ENTRYPOINT ["dotnet", "Mirea.Api.Endpoint.dll"]

View File

@ -1,6 +0,0 @@
@Backend_HostAddress = http://localhost:5269
GET {{Backend_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

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

View File

@ -0,0 +1,26 @@
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)]
public class CacheMaxAgeAttribute : Attribute
{
public int MaxAge { get; }
public CacheMaxAgeAttribute(int days = 0, int hours = 0, int minutes = 0)
{
MaxAge = (int)new TimeSpan(days, hours, minutes, 0).TotalSeconds;
}
public CacheMaxAgeAttribute(int minutes) : this(0, 0, minutes)
{
}
public CacheMaxAgeAttribute(bool usingSetting = false)
{
if (usingSetting)
MaxAge = -1;
else
MaxAge = 0;
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Net;
namespace Mirea.Api.Endpoint.Common.Attributes;
public class LocalhostAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var ip = context.HttpContext.Connection.RemoteIpAddress;
if (ip == null)
{
context.Result = new UnauthorizedResult();
return;
}
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();
}
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Parameter)]
public class SwaggerDefaultAttribute(string value) : Attribute
{
public string Value { get; } = value;
}

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Endpoint.Common.Interfaces;
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public class TokenAuthenticationAttribute : Attribute, IActionFilter
{
public const string AuthToken = "AuthToken";
public void OnActionExecuting(ActionExecutingContext context)
{
var setupToken = context.HttpContext.RequestServices.GetRequiredService<ISetupToken>();
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out var tokenFromCookie))
{
context.Result = new UnauthorizedResult();
return;
}
if (setupToken.MatchToken(Convert.FromBase64String(tokenFromCookie))) return;
context.Result = new UnauthorizedResult();
}
public void OnActionExecuted(ActionExecutedContext context) { }
}

View File

@ -0,0 +1,5 @@
using System;
namespace Mirea.Api.Endpoint.Common.Exceptions;
public class ControllerArgumentException(string message) : Exception(message);

View File

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

View File

@ -0,0 +1,7 @@
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface IMaintenanceModeNotConfigureService
{
bool IsMaintenanceMode { get; }
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface IMaintenanceModeService
{
bool IsMaintenanceMode { get; }
void EnableMaintenanceMode();
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,9 @@
using System;
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface ISetupToken
{
bool MatchToken(ReadOnlySpan<byte> token);
void SetToken(ReadOnlySpan<byte> token);
}

View File

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

View File

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

View File

@ -0,0 +1,14 @@
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
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));
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
using Mirea.Api.Endpoint.Common.Interfaces;
namespace Mirea.Api.Endpoint.Common.Services;
public class MaintenanceModeNotConfigureService : IMaintenanceModeNotConfigureService
{
public bool IsMaintenanceMode { get; private set; } = true;
public void DisableMaintenanceMode() =>
IsMaintenanceMode = false;
}

View File

@ -0,0 +1,14 @@
using Mirea.Api.Endpoint.Common.Interfaces;
namespace Mirea.Api.Endpoint.Common.Services;
public class MaintenanceModeService : IMaintenanceModeService
{
public bool IsMaintenanceMode { get; private set; }
public void EnableMaintenanceMode() =>
IsMaintenanceMode = true;
public void DisableMaintenanceMode() =>
IsMaintenanceMode = false;
}

View File

@ -0,0 +1,12 @@
using System;
using System.IO;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class PathBuilder
{
public static bool IsDefaultPath => Environment.GetEnvironmentVariable("PATH_TO_SAVE") == null;
public static string PathToSave => Environment.GetEnvironmentVariable("PATH_TO_SAVE") ?? Directory.GetCurrentDirectory();
public static string Combine(params string[] paths) => Path.Combine([.. paths.Prepend(PathToSave)]);
}

View 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();
}

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.Caching.Distributed;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow,
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);
}
public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
cache.RemoveAsync(key, cancellationToken);
}

View File

@ -0,0 +1,82 @@
using Microsoft.IdentityModel.Tokens;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
namespace Mirea.Api.Endpoint.Common.Services.Security;
public class JwtTokenService : IAccessToken
{
public required string Issuer { private get; init; }
public required string Audience { private get; init; }
public TimeSpan Lifetime { private get; init; }
public ReadOnlyMemory<byte> EncryptionKey { private get; init; }
public ReadOnlyMemory<byte> SigningKey { private get; init; }
public (string Token, DateTime ExpireIn) GenerateToken(string userId)
{
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey = new SymmetricSecurityKey(SigningKey.ToArray());
var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray());
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512);
var expires = DateTime.UtcNow.Add(Lifetime);
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = Audience,
Expires = expires,
SigningCredentials = signingCredentials,
Subject = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, userId),
// todo: get role by userId
new Claim(ClaimTypes.Role, "")
]),
EncryptingCredentials = new EncryptingCredentials(encryptionKey, SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return (tokenHandler.WriteToken(token), expires);
}
public DateTimeOffset GetExpireDateTime(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey = new SymmetricSecurityKey(SigningKey.ToArray());
var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray());
var tokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = Issuer,
ValidAudience = Audience,
IssuerSigningKey = signingKey,
TokenDecryptionKey = encryptionKey,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = false
};
try
{
var claimsPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out _);
var expClaim = claimsPrincipal.Claims.FirstOrDefault(c => c.Type == "exp");
if (expClaim != null && long.TryParse(expClaim.Value, out var expUnix))
return DateTimeOffset.FromUnixTimeSeconds(expUnix);
}
catch (SecurityTokenException)
{
return DateTimeOffset.MinValue;
}
return DateTimeOffset.MinValue;
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow,
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)
{
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)
{
cache.Remove(key);
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Common.Services.Security;
public class MemoryRevokedTokenService(IMemoryCache cache) : IRevokedToken
{
public Task AddTokenToRevokedAsync(string token, DateTimeOffset expiresIn)
{
cache.Set(token, true, expiresIn);
return Task.CompletedTask;
}
public Task<bool> IsTokenRevokedAsync(string token) => Task.FromResult(cache.TryGetValue(token, out _));
}

View File

@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Http;
using System;
using System.Linq;
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)
{
if (string.IsNullOrEmpty(path))
return "/";
return "/" + path.Trim('/') + "/";
}
public static string GetSubPath => CreateSubPath(Environment.GetEnvironmentVariable("ACTUAL_SUB_PATH"));
public static string GetSubPathWithoutFirstApiName
{
get
{
var path = GetSubPath;
if (string.IsNullOrEmpty(path) || path == "/")
return CreateSubPath(null);
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts[^1].Equals("api", StringComparison.CurrentCultureIgnoreCase))
parts = parts.Take(parts.Length - 1).ToArray();
return CreateSubPath(string.Join("/", parts));
}
}
public static string GetSubPathSwagger => CreateSubPath(Environment.GetEnvironmentVariable("SWAGGER_SUB_PATH"));
public static string GetApiUrl(this HttpContext context, string apiPath = "")
{
var scheme = GetCurrentScheme(context);
var domain = GetCurrentDomain(context).TrimEnd('/').Replace("localhost", "127.0.0.1");
var port = GetCurrentPort(context);
var portString = port.HasValue && port != 80 && port != 443 ? $":{port}" : string.Empty;
return $"{scheme}://{domain}{portString}{GetSubPathWithoutFirstApiName}{apiPath.Trim('/')}";
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,81 @@
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Configuration.Model;
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class CacheMaxAgeMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
public async Task InvokeAsync(HttpContext context)
{
if (!context.Response.StatusCode.ToString().StartsWith('2'))
{
await next(context);
return;
}
var endpoint = context.GetEndpoint();
var actionDescriptor = endpoint?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
if (actionDescriptor == null)
{
await next(context);
return;
}
var controllerType = actionDescriptor.ControllerTypeInfo;
var methodInfo = actionDescriptor.MethodInfo;
var maxAgeAttribute = methodInfo.GetCustomAttribute<CacheMaxAgeAttribute>() ?? controllerType.GetCustomAttribute<CacheMaxAgeAttribute>();
if (maxAgeAttribute == null)
{
await next(context);
return;
}
switch (maxAgeAttribute.MaxAge)
{
case < 0:
{
DateTime? nextDate;
var now = DateTime.UtcNow;
using (var scope = serviceProvider.CreateScope())
{
var updateCronString = scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<GeneralConfig>>().Value.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(updateCronString) ||
!CronExpression.TryParse(updateCronString, CronFormat.Standard, out var updateCron))
{
await next(context);
return;
}
nextDate = updateCron.GetNextOccurrence(now);
}
if (!nextDate.HasValue)
{
await next(context);
return;
}
context.Response.Headers.CacheControl = "max-age=" + (int)(nextDate.Value - now).TotalSeconds;
break;
}
case > 0:
context.Response.Headers.CacheControl = "max-age=" + maxAgeAttribute.MaxAge;
break;
}
await next(context);
}
}

View File

@ -0,0 +1,16 @@
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 async Task InvokeAsync(HttpContext context)
{
if (context.Request.Cookies.ContainsKey(CookieNames.AccessToken))
context.Request.Headers.Authorization = "Bearer " + context.Request.Cookies[CookieNames.AccessToken];
await next(context);
}
}

View File

@ -0,0 +1,94 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Mirea.Api.DataAccess.Application.Common.Exceptions;
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, ILogger<CustomExceptionHandlerMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception exception)
{
await HandleExceptionAsync(context, exception);
}
}
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
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:
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?>
{
{ "errors", validationException.Errors.Select(e => e.ErrorMessage).ToArray() },
{ "traceId", traceId }
};
break;
case NotFoundException:
problemDetails.Status = StatusCodes.Status404NotFound;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4";
problemDetails.Title = "Resource not found.";
break;
case ControllerArgumentException:
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 = problemDetails.Status.Value;
return context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails));
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Common.Interfaces;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class JwtRevocationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IRevokedToken revokedTokenStore)
{
if (context.Request.Headers.ContainsKey("Authorization"))
{
var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
if (await revokedTokenStore.IsTokenRevokedAsync(token))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
await next(context);
}
}

View File

@ -0,0 +1,34 @@
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;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeService maintenanceModeService, IMaintenanceModeNotConfigureService maintenanceModeNotConfigureService)
{
private static bool IsIgnoreMaintenanceMode(HttpContext context)
{
var endpoint = context.GetEndpoint();
return endpoint?.Metadata.GetMetadata<MaintenanceModeIgnoreAttribute>() != null;
}
public async Task InvokeAsync(HttpContext context)
{
if (!maintenanceModeService.IsMaintenanceMode && !maintenanceModeNotConfigureService.IsMaintenanceMode || IsIgnoreMaintenanceMode(context))
await next(context);
else
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "plain/text";
if (maintenanceModeService.IsMaintenanceMode)
throw new ServerUnavailableException("The service is currently undergoing maintenance. Please try again later.", true);
throw new ServerUnavailableException(
"The service is currently not configured. Go to the setup page if you are an administrator or try again later.", false);
}
}
}

View File

@ -0,0 +1,22 @@
using Asp.Versioning;
using Microsoft.Extensions.DependencyInjection;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class ApiVersioningConfiguration
{
public static IApiVersioningBuilder AddCustomApiVersioning(this IServiceCollection services)
{
return services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
}
}

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Model;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class CacheConfiguration
{
public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthChecksBuilder = null)
{
var cache = configuration.Get<GeneralConfig>()?.CacheSettings;
if (cache?.TypeDatabase != CacheType.Redis)
return services;
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cache.ConnectionString;
options.InstanceName = "mirea_";
});
healthChecksBuilder?.AddRedis(cache.ConnectionString!, name: "Redis");
return services;
}
}

View File

@ -0,0 +1,79 @@
using Microsoft.Extensions.Configuration;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class EnvironmentConfiguration
{
private static Dictionary<string, string> LoadEnvironment(string envFile)
{
Dictionary<string, string> environment = [];
if (!File.Exists(envFile)) return environment;
foreach (var line in File.ReadAllLines(envFile))
{
if (string.IsNullOrEmpty(line)) continue;
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
var arg = line;
if (commentIndex != -1)
arg = arg.Remove(commentIndex, arg.Length - commentIndex);
var parts = arg.Split(
'=',
StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 2)
parts = [parts[0], string.Join("=", parts[1..])];
if (parts.Length != 2)
continue;
environment.Add(parts[0].Trim(), parts[1].Trim());
}
return environment;
}
public static IConfigurationRoot GetEnvironment()
{
var variablesFromFile = LoadEnvironment(".env");
#if DEBUG
LoadEnvironment(".env.develop").ToList().ForEach(x => variablesFromFile.Add(x.Key, x.Value));
#endif
var environmentVariables = Environment.GetEnvironmentVariables()
.OfType<DictionaryEntry>()
.ToDictionary(
entry => entry.Key.ToString() ?? string.Empty,
entry => entry.Value?.ToString() ?? string.Empty
);
var result = new ConfigurationBuilder()
.AddInMemoryCollection(environmentVariables!)
.AddInMemoryCollection(variablesFromFile!);
if (variablesFromFile.TryGetValue("PATH_TO_SAVE", out var pathToSave))
{
Environment.SetEnvironmentVariable("PATH_TO_SAVE", pathToSave);
if (!Directory.Exists(pathToSave))
Directory.CreateDirectory(pathToSave);
}
if (variablesFromFile.TryGetValue("ACTUAL_SUB_PATH", out var actualSubPath))
Environment.SetEnvironmentVariable("ACTUAL_SUB_PATH", actualSubPath);
if (variablesFromFile.TryGetValue("SWAGGER_SUB_PATH", out var swaggerSubPath))
Environment.SetEnvironmentVariable("SWAGGER_SUB_PATH", swaggerSubPath);
return result.Build();
}
}

View File

@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Mirea.Api.Endpoint.Common.Services.Security;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class JwtConfiguration
{
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);
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);
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
if (string.IsNullOrEmpty(jwtAudience) || string.IsNullOrEmpty(jwtIssuer))
throw new InvalidOperationException("The \"SECURITY_JWT_ISSUER\" and \"SECURITY_JWT_AUDIENCE\" are not specified");
services.AddSingleton<IAccessToken, JwtTokenService>(_ => new JwtTokenService
{
Audience = jwtAudience,
Issuer = jwtIssuer,
Lifetime = lifeTimeJwt,
EncryptionKey = jwtDecrypt,
SigningKey = jwtKey
});
return services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtIssuer,
ValidateAudience = true,
ValidAudience = jwtAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(jwtKey),
TokenDecryptionKey = new SymmetricSecurityKey(jwtDecrypt)
};
});
}
}

View File

@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
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;
public static class LoggerConfiguration
{
public static IHostBuilder AddCustomSerilog(this IHostBuilder hostBuilder)
{
return hostBuilder.UseSerilog((context, _, configuration) =>
{
var generalConfig = context.Configuration.Get<GeneralConfig>()?.LogSettings;
configuration
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(
outputTemplate:
"[{Level:u3}] [{Timestamp:dd.MM.yyyy HH:mm:ss}] {Message:lj}{NewLine}{Exception}");
if (generalConfig?.EnableLogToFile == true)
{
generalConfig.LogFilePath = PathBuilder.Combine(generalConfig.LogFilePath ?? string.Empty);
if (!string.IsNullOrEmpty(generalConfig.LogFilePath) && Directory.Exists(generalConfig.LogFilePath))
Directory.CreateDirectory(generalConfig.LogFilePath);
configuration.WriteTo.File(
new CompactJsonFormatter(),
PathBuilder.Combine(
generalConfig.LogFilePath!,
generalConfig.LogFileName + ".json"
),
LogEventLevel.Debug,
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.Authentication", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authorization", LogEventLevel.Warning);
configuration.Filter.ByExcluding(Matching.WithProperty<string>("SourceContext", sc =>
sc.Contains("Microsoft.EntityFrameworkCore.Database.Command")));
});
}
public static IApplicationBuilder UseCustomSerilog(this IApplicationBuilder app)
{
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 = (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() ?? string.Empty);
};
});
}
}

View File

@ -0,0 +1,26 @@
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.Security;
using Mirea.Api.Security.Common.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SecureConfiguration
{
public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration)
{
services.AddSecurityServices(configuration);
services.AddSingleton<IRevokedToken, MemoryRevokedTokenService>();
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheType.Redis)
services.AddSingleton<ICacheService, DistributedCacheService>();
else
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
}

View File

@ -0,0 +1,77 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.SwaggerOptions;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SwaggerConfiguration
{
public static IServiceCollection AddCustomSwagger(this IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
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
{
In = ParameterLocation.Header,
Description = "Keep the JWT token in the field (Bearer token)",
Name = "Authorization",
Type = SecuritySchemeType.ApiKey
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
[]
}
});
if (File.Exists(Path.Combine(basePath, "docs.xml")))
options.IncludeXmlComments(Path.Combine(basePath, "docs.xml"));
if (File.Exists(Path.Combine(basePath, "ApiDtoDocs.xml")))
options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml"));
});
return services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
}
public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app, IServiceProvider services)
{
app.UseSwagger();
return app.UseSwaggerUI(options =>
{
options.InjectStylesheet($"{UrlHelper.GetSubPath}css/swagger/SwaggerDark.css");
var provider = services.GetService<IApiVersionDescriptionProvider>();
foreach (var description in provider!.ApiVersionDescriptions)
{
var url = $"/swagger/{description.GroupName}/swagger.json";
var name = description.GroupName.ToUpperInvariant();
options.SwaggerEndpoint(url, name);
options.RoutePrefix = UrlHelper.GetSubPathSwagger.Trim('/');
}
});
}
}

View File

@ -0,0 +1,5 @@
namespace Mirea.Api.Endpoint.Configuration;
public interface ISaveSettings
{
void SaveSetting();
}

View File

@ -0,0 +1,41 @@
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;
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 => _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()
{
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
}
}

View File

@ -0,0 +1,36 @@
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;
namespace Mirea.Api.Endpoint.Configuration.Model;
public class GeneralConfig : ISaveSettings
{
[JsonIgnore] private const string FileName = "Settings.json";
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public DbSettings? DbSettings { get; set; }
public CacheSettings? CacheSettings { get; set; }
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()
{
File.WriteAllText(
FilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true
})
);
}
}

View File

@ -0,0 +1,18 @@
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;
[RequiredSettings]
public class CacheSettings : IIsConfigured
{
public CacheType TypeDatabase { get; set; }
public string? ConnectionString { get; set; }
public bool IsConfigured()
{
return TypeDatabase == CacheType.Memcached ||
!string.IsNullOrEmpty(ConnectionString);
}
}

View File

@ -0,0 +1,28 @@
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;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class DbSettings : IIsConfigured
{
public DatabaseType TypeDatabase { get; set; }
public required string ConnectionStringSql { get; set; }
[JsonIgnore]
public DatabaseProvider DatabaseProvider =>
TypeDatabase switch
{
DatabaseType.PostgresSql => DatabaseProvider.Postgresql,
DatabaseType.Mysql => DatabaseProvider.Mysql,
DatabaseType.Sqlite => DatabaseProvider.Sqlite,
_ => throw new ArgumentOutOfRangeException()
};
public bool IsConfigured() =>
!string.IsNullOrEmpty(ConnectionStringSql);
}

View File

@ -0,0 +1,23 @@
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
public class EmailSettings : IIsConfigured
{
public string? Server { get; set; }
public string? User { get; set; }
public string? Password { get; set; }
public string? From { get; set; }
public int? Port { get; set; }
public bool? Ssl { get; set; }
public bool IsConfigured()
{
return !string.IsNullOrEmpty(Server) &&
!string.IsNullOrEmpty(User) &&
!string.IsNullOrEmpty(Password) &&
!string.IsNullOrEmpty(From) &&
Port.HasValue &&
Ssl.HasValue;
}
}

View File

@ -0,0 +1,21 @@
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
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()
{
return !EnableLogToFile ||
!string.IsNullOrEmpty(LogFilePath) &&
!string.IsNullOrEmpty(LogFileName);
}
}

View File

@ -0,0 +1,69 @@
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class ScheduleSettings : IIsConfigured
{
public struct PairPeriodTime
{
public TimeOnly Start { get; set; }
public TimeOnly End { get; set; }
public PairPeriodTime(TimeOnly t1, TimeOnly t2)
{
if (t1 > t2)
{
Start = t2;
End = t1;
}
else
{
Start = t1;
End = t2;
}
}
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()
{
return !string.IsNullOrEmpty(CronUpdateSchedule) &&
StartTerm != default &&
PairPeriod.Count != 0 &&
PairPeriod.Any();
}
}

View File

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

View File

@ -0,0 +1,37 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
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;
public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) : IConfigureOptions<SwaggerGenOptions>
{
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 ({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.",
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
};
if (description.IsDeprecated)
info.Description += " This API version has been deprecated.";
return info;
}
}

View File

@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class DefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var apiDescription = context.ApiDescription;
operation.Deprecated |= apiDescription.IsDeprecated();
foreach (var responseType in context.ApiDescription.SupportedResponseTypes)
{
var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
var response = operation.Responses[responseKey];
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)
{
var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
parameter.Description ??= description.ModelMetadata.Description;
if (parameter.Schema.Default == null &&
description.DefaultValue != null &&
description.DefaultValue is not DBNull &&
description.ModelMetadata is ModelMetadata modelMetadata)
{
var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType);
parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
}
parameter.Required |= description.IsRequired;
}
}
}

View File

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

View File

@ -0,0 +1,16 @@
using Microsoft.OpenApi.Models;
using Mirea.Api.Endpoint.Common.Attributes;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class ExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var att = context.ParameterInfo?.GetCustomAttribute<SwaggerDefaultAttribute>();
if (att != null)
schema.Example = new Microsoft.OpenApi.Any.OpenApiString(att.Value);
}
}

View File

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

View File

@ -0,0 +1,8 @@
using System;
namespace Mirea.Api.Endpoint.Configuration.Validation.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RequiredSettingsAttribute : Attribute;
// todo: only with IIsConfigured. If possible add Roslyn Analyzer later

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