Compare commits
9 Commits
dba9b516b8
...
main
Author | SHA1 | Date | |
---|---|---|---|
520335c26a | |||
f771dafcde | |||
7e1df403e2 | |||
428ee1d388 | |||
82e7d92584 | |||
dbfcaac425 | |||
fd1c033460 | |||
f15bf4dfe6 | |||
e3bcdfa4e6 |
@@ -5,7 +5,7 @@ on:
|
|||||||
branches: ['main', 'staging']
|
branches: ['main', 'staging']
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CONTEXT: SfeduSchedule
|
CONTEXT: ./
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine3.22 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine3.22 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY ./SfeduSchedule ./SfeduSchedule
|
||||||
|
COPY ./SfeduSchedule.Plugin.Abstractions ./SfeduSchedule.Plugin.Abstractions
|
||||||
|
WORKDIR /src/SfeduSchedule
|
||||||
RUN dotnet restore "SfeduSchedule.csproj"
|
RUN dotnet restore "SfeduSchedule.csproj"
|
||||||
RUN dotnet publish "SfeduSchedule.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "SfeduSchedule.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
@@ -1,6 +1,6 @@
|
|||||||
|
# Прокси для расписания в Modeus
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] Добавить RateLimiter
|
- [x] Добавить RateLimiter
|
||||||
- [ ] Добавить обработку ошибок при запросах к modeus
|
- [x] Добавить обработку ошибок при запросах к modeus
|
13
SfeduSchedule.Plugin.Abstractions/IPlugin.cs
Normal file
13
SfeduSchedule.Plugin.Abstractions/IPlugin.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SfeduSchedule.Plugin.Abstractions;
|
||||||
|
|
||||||
|
// Базовый контракт плагина (общий для хоста и плагинов)
|
||||||
|
public interface IPlugin
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
// Регистрация сервисов плагина в DI (выполняется до Build())
|
||||||
|
void ConfigureServices(IServiceCollection services);
|
||||||
|
|
||||||
|
// Регистрация маршрутов (Minimal API) плагина после Build()
|
||||||
|
void MapEndpoints(IEndpointRouteBuilder endpoints);
|
||||||
|
}
|
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
45
SfeduSchedule.Plugin.Sample/Plugin.cs
Normal file
45
SfeduSchedule.Plugin.Sample/Plugin.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SfeduSchedule.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace SfeduSchedule.Plugin.Sample;
|
||||||
|
|
||||||
|
// Пример сервиса плагина
|
||||||
|
public interface IGreeter
|
||||||
|
{
|
||||||
|
string Greet(string name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Greeter : IGreeter
|
||||||
|
{
|
||||||
|
public string Greet(string name) => $"Hello, {name} from {nameof(Plugin)}!";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SamplePlugin : IPlugin
|
||||||
|
{
|
||||||
|
public string Name => "Sample";
|
||||||
|
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Регистрируем DI-сервисы плагина
|
||||||
|
services.AddScoped<IGreeter, Greeter>();
|
||||||
|
// Можно регистрировать любые IHostedService, Options и т.д.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
// Пример Minimal API эндпоинта
|
||||||
|
endpoints.MapGet("/plugins/sample/hello", (IGreeter greeter) =>
|
||||||
|
{
|
||||||
|
return Results.Ok(new { message = greeter.Greet("world") });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пример MVC-контроллера из плагина
|
||||||
|
[ApiController]
|
||||||
|
[Route("plugins/sample/[controller]")]
|
||||||
|
public class EchoController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{text}")]
|
||||||
|
public IActionResult Get(string text) => Ok(new { echo = text, from = "Plugin.Sample" });
|
||||||
|
}
|
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>SfeduSchedule.Plugin.Sample.plugin</AssemblyName>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- Кладём зависимости плагина рядом со сборкой, чтобы загрузчик их нашёл -->
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Даёт доступ к Microsoft.AspNetCore.* (ControllerBase и т.п.) -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
|
<ProjectReference Include="..\SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
@@ -2,6 +2,10 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule", "SfeduSchedule\SfeduSchedule.csproj", "{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule", "SfeduSchedule\SfeduSchedule.csproj", "{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Abstractions", "SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj", "{B2E8DAD7-7373-4155-B230-4E53DFC04445}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Sample", "SfeduSchedule.Plugin.Sample\SfeduSchedule.Plugin.Sample.csproj", "{B2B6D730-46AE-40ED-815F-81176FB4E545}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -12,5 +16,13 @@ Global
|
|||||||
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.Build.0 = Release|Any CPU
|
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
7
SfeduSchedule/Auth/ApiKeyAuthenticationDefaults.cs
Normal file
7
SfeduSchedule/Auth/ApiKeyAuthenticationDefaults.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SfeduSchedule.Auth;
|
||||||
|
|
||||||
|
public static class ApiKeyAuthenticationDefaults
|
||||||
|
{
|
||||||
|
public const string Scheme = "ApiKey";
|
||||||
|
public const string HeaderName = "X-Api-Key";
|
||||||
|
}
|
72
SfeduSchedule/Auth/ApiKeyAuthenticationHandler.cs
Normal file
72
SfeduSchedule/Auth/ApiKeyAuthenticationHandler.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SfeduSchedule.Auth;
|
||||||
|
|
||||||
|
public class ApiKeyAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IConfiguration configuration)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// Ожидаем ключ из ENV
|
||||||
|
var expectedKey = configuration["API_KEY"];
|
||||||
|
if (string.IsNullOrEmpty(expectedKey))
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("API key is not configured."));
|
||||||
|
|
||||||
|
// Ищем ключ в заголовке X-Api-Key (и опционально в query ?api_key=)
|
||||||
|
string? providedKey = null;
|
||||||
|
|
||||||
|
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var values))
|
||||||
|
providedKey = values.FirstOrDefault();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(providedKey) &&
|
||||||
|
Request.Query.TryGetValue("api_key", out var qv))
|
||||||
|
providedKey = qv.FirstOrDefault();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(providedKey))
|
||||||
|
// Нет ключа — позволь другим схемам (если есть) продолжить; при [Authorize] будет 401
|
||||||
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
|
||||||
|
if (!ConstantTimeEquals(providedKey!, expectedKey))
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "api-key"),
|
||||||
|
new Claim(ClaimTypes.Name, "api-key-user"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.Scheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.Scheme);
|
||||||
|
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
Response.Headers["WWW-Authenticate"] =
|
||||||
|
$"{ApiKeyAuthenticationDefaults.Scheme} realm=\"api\", header=\"{ApiKeyAuthenticationDefaults.HeaderName}\"";
|
||||||
|
Response.StatusCode = 401;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ConstantTimeEquals(string a, string b)
|
||||||
|
{
|
||||||
|
var aBytes = Encoding.UTF8.GetBytes(a);
|
||||||
|
var bBytes = Encoding.UTF8.GetBytes(b);
|
||||||
|
|
||||||
|
if (aBytes.Length != bBytes.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return CryptographicOperations.FixedTimeEquals(aBytes, bBytes);
|
||||||
|
}
|
||||||
|
}
|
33
SfeduSchedule/Auth/SwaggerAuthorizeOperationFilter.cs
Normal file
33
SfeduSchedule/Auth/SwaggerAuthorizeOperationFilter.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace SfeduSchedule.Auth
|
||||||
|
{
|
||||||
|
public class SwaggerAuthorizeOperationFilter : IOperationFilter
|
||||||
|
{
|
||||||
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||||
|
{
|
||||||
|
var hasAuthorize = context.MethodInfo.GetCustomAttributes(true).OfType<AuthorizeAttribute>().Any() ||
|
||||||
|
context.MethodInfo.DeclaringType?.GetCustomAttributes(true).OfType<AuthorizeAttribute>().Any() == true;
|
||||||
|
if (hasAuthorize)
|
||||||
|
{
|
||||||
|
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = ApiKeyAuthenticationDefaults.Scheme
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new List<string>()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using SfeduSchedule.Services;
|
using SfeduSchedule.Services;
|
||||||
@@ -7,26 +9,8 @@ namespace SfeduSchedule.Controllers
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[EnableRateLimiting("throttle")]
|
[EnableRateLimiting("throttle")]
|
||||||
public class ScheduleController(ModeusService modeusService) : ControllerBase
|
public class ScheduleController(ModeusService modeusService, ILogger<ScheduleController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Получить расписание для указанных пользователей.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="attendeePersonId">Список GUID пользователей, для которых запрашивается расписание.</param>
|
|
||||||
/// <returns>Список событий расписания.</returns>
|
|
||||||
/// <response code="200">Возвращает расписание</response>
|
|
||||||
/// <response code="429">Слишком много запросов</response>
|
|
||||||
[HttpGet]
|
|
||||||
[Route("test")]
|
|
||||||
public async Task<IActionResult> Get([FromQuery] List<Guid> attendeePersonId, [FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate)
|
|
||||||
{
|
|
||||||
startDate ??= DateTime.UtcNow;
|
|
||||||
endDate ??= DateTime.UtcNow.AddDays(20);
|
|
||||||
|
|
||||||
var msr = new ModeusScheduleRequest(500, (DateTime)startDate, (DateTime)endDate, attendeePersonId);
|
|
||||||
var schedule = await modeusService.GetScheduleAsync(msr);
|
|
||||||
return Ok(schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Получить расписание по пользовательскому запросу.
|
/// Получить расписание по пользовательскому запросу.
|
||||||
@@ -38,8 +22,61 @@ namespace SfeduSchedule.Controllers
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Post([FromBody] ModeusScheduleRequest request)
|
public async Task<IActionResult> Post([FromBody] ModeusScheduleRequest request)
|
||||||
{
|
{
|
||||||
var schedule = await modeusService.GetScheduleAsync(request);
|
string? schedule;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
schedule = await modeusService.GetScheduleAsync(request);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "Ошибка при получении расписания");
|
||||||
|
return StatusCode((int)(e.StatusCode ?? HttpStatusCode.InternalServerError), e.Message);
|
||||||
|
}
|
||||||
return Ok(schedule);
|
return Ok(schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поиск аудиторий по пользовательскому запросу.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Объект запроса, содержащий параметры фильтрации аудиторий.</param>
|
||||||
|
/// <returns>Список аудиторий.</returns>
|
||||||
|
/// <response code="200">Возвращает список аудиторий</response>
|
||||||
|
/// <response code="429">Слишком много запросов</response>
|
||||||
|
[HttpPost]
|
||||||
|
[Route("rooms/search")]
|
||||||
|
public async Task<IActionResult> SearchRooms([FromBody] RoomSearchRequest request)
|
||||||
|
{
|
||||||
|
string? rooms;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rooms = await modeusService.SearchRoomsAsync(request);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "Ошибка при поиске аудиторий");
|
||||||
|
return StatusCode((int)(e.StatusCode ?? HttpStatusCode.InternalServerError), e.Message);
|
||||||
|
}
|
||||||
|
return Ok(rooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить GUID пользователя по полному имени. (требуется авторизация)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullname">Полное имя пользователя.</param>
|
||||||
|
/// <returns>GUID пользователя.</returns>
|
||||||
|
/// <response code="200">Возвращает GUID пользователя</response>
|
||||||
|
/// <response code="404">Пользователь не найден</response>
|
||||||
|
/// <response code="401">Неавторизованный</response>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(AuthenticationSchemes = "ApiKey")]
|
||||||
|
[Route("getguid")]
|
||||||
|
public async Task<IActionResult> GetGuid(string fullname)
|
||||||
|
{
|
||||||
|
var guid = await modeusService.GetGuidAsync(fullname);
|
||||||
|
if (string.IsNullOrEmpty(guid))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(guid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,7 @@ namespace SfeduSchedule.Controllers
|
|||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize]
|
[Authorize(AuthenticationSchemes = "OpenIdConnect")]
|
||||||
public class SfeduController : ControllerBase
|
public class SfeduController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ModeusService _modeusService;
|
private readonly ModeusService _modeusService;
|
||||||
@@ -20,7 +20,8 @@ namespace SfeduSchedule.Controllers
|
|||||||
/// <returns>Строка GUID пользователя или редирект на указанный URI.</returns>
|
/// <returns>Строка GUID пользователя или редирект на указанный URI.</returns>
|
||||||
/// <response code="200">Возвращает GUID пользователя</response>
|
/// <response code="200">Возвращает GUID пользователя</response>
|
||||||
/// <response code="302">Редирект на указанный URI</response>
|
/// <response code="302">Редирект на указанный URI</response>
|
||||||
/// <response code="500">Ошибка при получении имени пользователя или GUID</response>
|
/// <response code="404">Пользователь не найден</response>
|
||||||
|
/// <response code="401">Неавторизованный</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("guid")]
|
[Route("guid")]
|
||||||
public async Task<IActionResult> Get([FromQuery] string? redirectUri)
|
public async Task<IActionResult> Get([FromQuery] string? redirectUri)
|
||||||
@@ -31,7 +32,7 @@ namespace SfeduSchedule.Controllers
|
|||||||
|
|
||||||
var guid = await _modeusService.GetGuidAsync(name);
|
var guid = await _modeusService.GetGuidAsync(name);
|
||||||
if (string.IsNullOrEmpty(guid))
|
if (string.IsNullOrEmpty(guid))
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
return NotFound();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(redirectUri))
|
if (!string.IsNullOrEmpty(redirectUri))
|
||||||
{
|
{
|
||||||
|
29
SfeduSchedule/DTO/ModeusScheduleRequestDTO.cs
Normal file
29
SfeduSchedule/DTO/ModeusScheduleRequestDTO.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace SfeduSchedule
|
||||||
|
{
|
||||||
|
public class ModeusScheduleRequest(int size, DateTime timeMin, DateTime timeMax, List<Guid>? attendeePersonId, List<Guid>? roomId)
|
||||||
|
{
|
||||||
|
[DefaultValue(10)]
|
||||||
|
public int Size { get; set; } = size;
|
||||||
|
public DateTime TimeMin { get; set; } = timeMin;
|
||||||
|
public DateTime TimeMax { get; set; } = timeMax;
|
||||||
|
public List<Guid>? AttendeePersonId { get; set; } = attendeePersonId;
|
||||||
|
public List<Guid>? RoomId { get; set; } = roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RoomSearchRequest
|
||||||
|
{
|
||||||
|
[DefaultValue("")]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
[DefaultValue("+building.name,+name")]
|
||||||
|
public string Sort { get; set; } = "+building.name,+name";
|
||||||
|
[DefaultValue(10)]
|
||||||
|
public int Size { get; set; } = 10;
|
||||||
|
[DefaultValue(0)]
|
||||||
|
public int Page { get; set; } = 0;
|
||||||
|
[DefaultValue(false)]
|
||||||
|
public bool Deleted { get; set; } = false;
|
||||||
|
public RoomSearchRequest() {}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,10 +0,0 @@
|
|||||||
namespace SfeduSchedule
|
|
||||||
{
|
|
||||||
public class ModeusScheduleRequest(int size, DateTime timeMin, DateTime timeMax, List<Guid> attendeePersonId)
|
|
||||||
{
|
|
||||||
public int Size { get; set; } = size;
|
|
||||||
public DateTime TimeMin { get; set; } = timeMin;
|
|
||||||
public DateTime TimeMax { get; set; } = timeMax;
|
|
||||||
public List<Guid> AttendeePersonId { get; set; } = attendeePersonId;
|
|
||||||
}
|
|
||||||
}
|
|
66
SfeduSchedule/PluginLoader.cs
Normal file
66
SfeduSchedule/PluginLoader.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
using SfeduSchedule.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace SfeduSchedule;
|
||||||
|
|
||||||
|
public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context);
|
||||||
|
|
||||||
|
public static class PluginLoader
|
||||||
|
{
|
||||||
|
// Загружаем все сборки *.plugin.dll из папки плагинов
|
||||||
|
public static IReadOnlyList<LoadedPlugin> LoadPlugins(string pluginsDir)
|
||||||
|
{
|
||||||
|
var result = new List<LoadedPlugin>();
|
||||||
|
|
||||||
|
if (!Directory.Exists(pluginsDir))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(pluginsDir, "*.plugin.dll", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var path = Path.GetFullPath(file);
|
||||||
|
var alc = new PluginLoadContext(path);
|
||||||
|
var asm = alc.LoadFromAssemblyPath(path);
|
||||||
|
|
||||||
|
// Ищем реализацию IPlugin
|
||||||
|
var pluginType = asm
|
||||||
|
.GetTypes()
|
||||||
|
.FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface);
|
||||||
|
|
||||||
|
if (pluginType is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
|
||||||
|
result.Add(new LoadedPlugin(instance, asm, alc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отдельный контекст загрузки для изоляции зависимостей плагина
|
||||||
|
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
private readonly AssemblyDependencyResolver _resolver;
|
||||||
|
|
||||||
|
public PluginLoadContext(string pluginMainAssemblyPath)
|
||||||
|
: base(isCollectible: true)
|
||||||
|
{
|
||||||
|
_resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрешаем управляемые зависимости плагина из его папки.
|
||||||
|
// Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, SfeduSchedule.Plugin.Abstractions).
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
var path = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||||
|
return path is null ? null : LoadFromAssemblyPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нативные зависимости (если есть)
|
||||||
|
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||||
|
{
|
||||||
|
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||||
|
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,6 @@
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.Identity.Web;
|
using Microsoft.Identity.Web;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using SfeduSchedule;
|
using SfeduSchedule;
|
||||||
@@ -6,6 +8,8 @@ using SfeduSchedule.Jobs;
|
|||||||
using SfeduSchedule.Services;
|
using SfeduSchedule.Services;
|
||||||
using X.Extensions.Logging.Telegram.Extensions;
|
using X.Extensions.Logging.Telegram.Extensions;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
||||||
|
using SfeduSchedule.Auth;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -13,7 +17,10 @@ var configuration = builder.Configuration;
|
|||||||
string? preinstalledJwtToken = configuration["TOKEN"];
|
string? preinstalledJwtToken = configuration["TOKEN"];
|
||||||
string? tgChatId = configuration["TG_CHAT_ID"];
|
string? tgChatId = configuration["TG_CHAT_ID"];
|
||||||
string? tgToken = configuration["TG_TOKEN"];
|
string? tgToken = configuration["TG_TOKEN"];
|
||||||
string updateJwtCron = configuration["UPDATE_JWT_CRON"] ?? "0 4 * ? * *";
|
string updateJwtCron = configuration["UPDATE_JWT_CRON"] ?? "0 0 4 ? * *";
|
||||||
|
|
||||||
|
int permitLimit = int.TryParse(configuration["PERMIT_LIMIT"], out var parsedPermitLimit) ? parsedPermitLimit : 40;
|
||||||
|
int timeLimit = int.TryParse(configuration["TIME_LIMIT"], out var parsedTimeLimit) ? parsedTimeLimit : 10;
|
||||||
|
|
||||||
// создать папку data если не существует
|
// создать папку data если не существует
|
||||||
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "data");
|
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "data");
|
||||||
@@ -23,7 +30,7 @@ if (!Directory.Exists(dataDirectory))
|
|||||||
}
|
}
|
||||||
|
|
||||||
GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
|
GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
|
||||||
|
var pluginsPath = Path.Combine(dataDirectory, "Plugins");
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
@@ -42,11 +49,29 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
// Включаем MVC контроллеры
|
||||||
|
var mvcBuilder = builder.Services.AddControllers();
|
||||||
builder.Services.AddHttpClient<ModeusService>();
|
builder.Services.AddHttpClient<ModeusService>();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication()
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||||
|
ApiKeyAuthenticationDefaults.Scheme, _ => { });
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration);
|
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration);
|
||||||
|
|
||||||
|
// Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры
|
||||||
|
var loaded = PluginLoader.LoadPlugins(pluginsPath);
|
||||||
|
foreach (var p in loaded)
|
||||||
|
{
|
||||||
|
// DI из плагина
|
||||||
|
p.Instance.ConfigureServices(builder.Services);
|
||||||
|
|
||||||
|
// Подключаем контроллеры плагина
|
||||||
|
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
|
||||||
|
}
|
||||||
|
|
||||||
var jobKey = new JobKey("UpdateJWTJob");
|
var jobKey = new JobKey("UpdateJWTJob");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
||||||
@@ -71,6 +96,17 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||||
options.IncludeXmlComments(xmlPath);
|
options.IncludeXmlComments(xmlPath);
|
||||||
|
|
||||||
|
// Добавляем только схему авторизации по ApiKey
|
||||||
|
options.AddSecurityDefinition(ApiKeyAuthenticationDefaults.Scheme, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Description = $"Api Key needed to access the endpoints. {ApiKeyAuthenticationDefaults.HeaderName}: Your_API_Key",
|
||||||
|
Name = ApiKeyAuthenticationDefaults.HeaderName,
|
||||||
|
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||||||
|
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
|
||||||
|
Scheme = ApiKeyAuthenticationDefaults.Scheme
|
||||||
|
});
|
||||||
|
options.OperationFilter<SwaggerAuthorizeOperationFilter>();
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
@@ -82,14 +118,14 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
: (httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"),
|
: (httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"),
|
||||||
factory: _ => new FixedWindowRateLimiterOptions
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = 40,
|
PermitLimit = permitLimit,
|
||||||
Window = TimeSpan.FromSeconds(10)
|
Window = TimeSpan.FromSeconds(timeLimit)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
options.OnRejected = async (context, cancellationToken) =>
|
options.OnRejected = async (context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
context.HttpContext.Response.Headers["Retry-After"] = "60";
|
context.HttpContext.Response.Headers["Retry-After"] = timeLimit.ToString();
|
||||||
|
|
||||||
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.",
|
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -114,7 +150,10 @@ var app = builder.Build();
|
|||||||
|
|
||||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
|
||||||
|
});
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
||||||
{
|
{
|
||||||
@@ -152,14 +191,10 @@ if (string.IsNullOrEmpty(preinstalledJwtToken))
|
|||||||
await scheduler.TriggerJob(jobKey);
|
await scheduler.TriggerJob(jobKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
@@ -172,6 +207,13 @@ app.MapGet("/", async context =>
|
|||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Маршруты Minimal API из плагинов
|
||||||
|
foreach (var p in loaded)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Mapping endpoints for plugin: {PluginName}", p.Instance.Name);
|
||||||
|
p.Instance.MapEndpoints(app);
|
||||||
|
}
|
||||||
|
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
@@ -31,6 +31,16 @@ namespace SfeduSchedule.Services
|
|||||||
return await response.Content.ReadAsStringAsync();
|
return await response.Content.ReadAsStringAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> SearchRoomsAsync(RoomSearchRequest requestDto)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"schedule-calendar-v2/api/campus/rooms/search");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, _options), System.Text.Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
_logger.LogInformation("SearchRoomsAsync: Ответ получен: {StatusCode}", response.StatusCode);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetGuidAsync(string fullName)
|
public async Task<string?> GetGuidAsync(string fullName)
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, $"schedule-calendar-v2/api/people/persons/search");
|
var request = new HttpRequestMessage(HttpMethod.Post, $"schedule-calendar-v2/api/people/persons/search");
|
||||||
|
@@ -10,7 +10,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
|
|
||||||
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.0" />
|
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.0" />
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||||
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
|
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
|
||||||
@@ -18,4 +17,8 @@
|
|||||||
<PackageReference Include="X.Extensions.Logging.Telegram" Version="2.0.2" />
|
<PackageReference Include="X.Extensions.Logging.Telegram" Version="2.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -15,6 +15,7 @@ services:
|
|||||||
- MS_PASSWORD=${MS_PASSWORD}
|
- MS_PASSWORD=${MS_PASSWORD}
|
||||||
- TG_CHAT_ID=${TG_CHAT_ID}
|
- TG_CHAT_ID=${TG_CHAT_ID}
|
||||||
- TG_TOKEN=${TG_TOKEN}
|
- TG_TOKEN=${TG_TOKEN}
|
||||||
|
- API_KEY=${API_KEY}
|
||||||
# - TOKEN=${TOKEN}
|
# - TOKEN=${TOKEN}
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
|
@@ -15,6 +15,7 @@ services:
|
|||||||
- MS_PASSWORD=${MS_PASSWORD}
|
- MS_PASSWORD=${MS_PASSWORD}
|
||||||
- TG_CHAT_ID=${TG_CHAT_ID}
|
- TG_CHAT_ID=${TG_CHAT_ID}
|
||||||
- TG_TOKEN=${TG_TOKEN}
|
- TG_TOKEN=${TG_TOKEN}
|
||||||
|
- API_KEY=${API_KEY}
|
||||||
# - TOKEN=${TOKEN}
|
# - TOKEN=${TOKEN}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
Reference in New Issue
Block a user