Compare commits

..

13 Commits

Author SHA1 Message Date
520335c26a Попытка фикса проблем с авторизацией
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 4m32s
2025-09-12 17:17:41 +03:00
f771dafcde Добавил получение расписания по roomId
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 2m56s
2025-09-09 17:22:25 +03:00
7e1df403e2 Удалил тестовый запрос 2025-09-09 17:22:06 +03:00
428ee1d388 Добавил поиск аудиторий и обработку ошибок от вышестоящего сервиса
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 4m30s
2025-09-09 17:00:57 +03:00
82e7d92584 Добавил получение guid пользователя по фио через токен
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 3m54s
2025-09-08 16:52:14 +03:00
dbfcaac425 Обновил CI/CD
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 7m46s
2025-09-07 16:04:52 +03:00
fd1c033460 Добавил систему плагинов
Some checks failed
Create and publish a Docker image / Publish image (push) Failing after 4m3s
2025-09-07 15:47:25 +03:00
f15bf4dfe6 Добавил управление RateLimiter из ENV
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 4m14s
2025-09-07 10:13:39 +03:00
e3bcdfa4e6 Исправил CRON 2025-09-07 10:13:21 +03:00
dba9b516b8 Завершил работу с RateLimiter
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 4m35s
2025-09-06 23:37:14 +03:00
bb2e6a24d7 Добавил X Forwarded Proto
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 4m7s
2025-09-06 22:40:18 +03:00
ea254c1c02 Добавил RateLimiter
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 11m22s
2025-09-06 22:07:37 +03:00
5f52e16dbd Добавил логирование в телеграм 2025-09-06 21:04:59 +03:00
22 changed files with 529 additions and 61 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: ['main', 'staging']
env:
CONTEXT: SfeduSchedule
CONTEXT: ./
jobs:
build-and-push-image:

View File

@@ -1,7 +1,9 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine3.22 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
COPY ./SfeduSchedule ./SfeduSchedule
COPY ./SfeduSchedule.Plugin.Abstractions ./SfeduSchedule.Plugin.Abstractions
WORKDIR /src/SfeduSchedule
RUN dotnet restore "SfeduSchedule.csproj"
RUN dotnet publish "SfeduSchedule.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

View File

@@ -1,6 +1,6 @@
# Прокси для расписания в Modeus
## TODO
- [ ] Добавить RateLimiter
- [ ] Добавить обработку ошибок при запросах к modeus
- [x] Добавить RateLimiter
- [x] Добавить обработку ошибок при запросах к modeus

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

View File

@@ -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>

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

View File

@@ -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>

View File

@@ -2,6 +2,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule", "SfeduSchedule\SfeduSchedule.csproj", "{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
EndGlobal

View File

@@ -0,0 +1,7 @@
namespace SfeduSchedule.Auth;
public static class ApiKeyAuthenticationDefaults
{
public const string Scheme = "ApiKey";
public const string HeaderName = "X-Api-Key";
}

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

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

View File

@@ -1,29 +1,16 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using SfeduSchedule.Services;
namespace SfeduSchedule.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ScheduleController(ModeusService modeusService) : ControllerBase
[EnableRateLimiting("throttle")]
public class ScheduleController(ModeusService modeusService, ILogger<ScheduleController> logger) : ControllerBase
{
/// <summary>
/// Получить расписание для указанных пользователей.
/// </summary>
/// <param name="attendeePersonId">Список GUID пользователей, для которых запрашивается расписание.</param>
/// <returns>Список событий расписания.</returns>
/// <response code="200">Возвращает расписание</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>
/// Получить расписание по пользовательскому запросу.
@@ -31,11 +18,65 @@ namespace SfeduSchedule.Controllers
/// <param name="request">Объект запроса, содержащий параметры фильтрации расписания.</param>
/// <returns>Список событий расписания.</returns>
/// <response code="200">Возвращает расписание</response>
/// <response code="429">Слишком много запросов</response>
[HttpPost]
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);
}
/// <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);
}
}
}

View File

@@ -6,7 +6,7 @@ namespace SfeduSchedule.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize]
[Authorize(AuthenticationSchemes = "OpenIdConnect")]
public class SfeduController : ControllerBase
{
private readonly ModeusService _modeusService;
@@ -20,7 +20,8 @@ namespace SfeduSchedule.Controllers
/// <returns>Строка GUID пользователя или редирект на указанный URI.</returns>
/// <response code="200">Возвращает GUID пользователя</response>
/// <response code="302">Редирект на указанный URI</response>
/// <response code="500">Ошибка при получении имени пользователя или GUID</response>
/// <response code="404">Пользователь не найден</response>
/// <response code="401">Неавторизованный</response>
[HttpGet]
[Route("guid")]
public async Task<IActionResult> Get([FromQuery] string? redirectUri)
@@ -31,7 +32,7 @@ namespace SfeduSchedule.Controllers
var guid = await _modeusService.GetGuidAsync(name);
if (string.IsNullOrEmpty(guid))
return StatusCode(StatusCodes.Status500InternalServerError);
return NotFound();
if (!string.IsNullOrEmpty(redirectUri))
{

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

View File

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

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

View File

@@ -1,14 +1,26 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using Quartz;
using SfeduSchedule;
using SfeduSchedule.Jobs;
using SfeduSchedule.Services;
using X.Extensions.Logging.Telegram.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using SfeduSchedule.Auth;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
string? preinstsalledJwtToken = configuration["TOKEN"];
string updateJwtCron = configuration["UPDATE_JWT_CRON"] ?? "0 4 * ? * *";
string? preinstalledJwtToken = configuration["TOKEN"];
string? tgChatId = configuration["TG_CHAT_ID"];
string? tgToken = configuration["TG_TOKEN"];
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 если не существует
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "data");
@@ -16,17 +28,53 @@ if (!Directory.Exists(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
builder.Services.AddOpenApi();
builder.Services.AddControllers();
GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
var pluginsPath = Path.Combine(dataDirectory, "Plugins");
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
builder.Logging.AddTelegram(options =>
{
options.ChatId = tgChatId;
options.AccessToken = tgToken;
options.FormatterConfiguration.UseEmoji = true;
options.FormatterConfiguration.ReadableApplicationName = "Sfedu Schedule";
options.LogLevel = new Dictionary<string, LogLevel>
{
{ "Default", LogLevel.Error },
{ "SfeduSchedule.Jobs.UpdateJwtJob", LogLevel.Information },
{ "Program", LogLevel.Information }
};
});
// Включаем MVC контроллеры
var mvcBuilder = builder.Services.AddControllers();
builder.Services.AddHttpClient<ModeusService>();
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationDefaults.Scheme, _ => { });
builder.Services.AddAuthorization();
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");
if (string.IsNullOrEmpty(preinstsalledJwtToken))
if (string.IsNullOrEmpty(preinstalledJwtToken))
{
builder.Services.AddQuartz(q =>
{
@@ -38,7 +86,7 @@ if (string.IsNullOrEmpty(preinstsalledJwtToken))
.WithCronSchedule(updateJwtCron)
);
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
}
@@ -48,15 +96,66 @@ builder.Services.AddSwaggerGen(options =>
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
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 =>
{
options.AddPolicy("throttle", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrWhiteSpace(xff.ToString()))
? xff.ToString().Split(',')[0].Trim()
: (httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromSeconds(timeLimit)
}));
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers["Retry-After"] = timeLimit.ToString();
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.",
cancellationToken);
var reqLogger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
var clientIp = (context.HttpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrWhiteSpace(xff.ToString()))
? xff.ToString().Split(',')[0].Trim()
: context.HttpContext.Connection.RemoteIpAddress?.ToString();
reqLogger.LogWarning("Rate limit exceeded for IP: {IpAddress}", clientIp);
};
});
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
if (string.IsNullOrEmpty(preinstsalledJwtToken))
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
});
if (string.IsNullOrEmpty(preinstalledJwtToken))
{
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
@@ -83,7 +182,8 @@ if (string.IsNullOrEmpty(preinstsalledJwtToken))
}
else
{
logger.LogInformation("Файл jwt.txt не содержит дату истечения или она некорректна, выполняем обновление токена");
logger.LogInformation(
"Файл jwt.txt не содержит дату истечения или она некорректна, выполняем обновление токена");
await scheduler.TriggerJob(jobKey);
}
}
@@ -91,14 +191,10 @@ if (string.IsNullOrEmpty(preinstsalledJwtToken))
await scheduler.TriggerJob(jobKey);
}
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization();
app.UseStaticFiles();
@@ -106,10 +202,18 @@ app.UseStaticFiles();
app.MapGet("/", async context =>
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath ?? "wwwroot", "index.html"));
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath, "index.html"));
});
app.MapControllers();
app.Run();
// Маршруты Minimal API из плагинов
foreach (var p in loaded)
{
logger.LogInformation("Mapping endpoints for plugin: {PluginName}", p.Instance.Name);
p.Instance.MapEndpoints(app);
}
app.UseRateLimiter();
app.Run();

View File

@@ -31,6 +31,16 @@ namespace SfeduSchedule.Services
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)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"schedule-calendar-v2/api/people/persons/search");

View File

@@ -10,11 +10,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="X.Extensions.Logging.Telegram" Version="2.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -4,6 +4,7 @@ services:
- '8088:8080'
container_name: SfeduSchedule
environment:
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
- AzureAd:Instance=https://login.microsoftonline.com/
- AzureAd:TenantId=sfedu.ru
- AzureAd:ClientId=
@@ -12,7 +13,10 @@ services:
- AzureAd:CallbackPath=/signin-oidc
- MS_USERNAME=${MS_USERNAME}
- MS_PASSWORD=${MS_PASSWORD}
# - TOKEN=
- TG_CHAT_ID=${TG_CHAT_ID}
- TG_TOKEN=${TG_TOKEN}
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN}
volumes:
- data:/app/data
restart: always

View File

@@ -4,15 +4,19 @@ services:
- '8088:8080'
container_name: SfeduSchedule
environment:
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
- AzureAd:Instance=https://login.microsoftonline.com/
- AzureAd:TenantId=sfedu.ru
- AzureAd:ClientId=
- AzureAd:ClientSecret=
- AzureAd:Domain=sfedu.onmicrosoft.com
- AzureAd:CallbackPath=/signin-oidc
- MS_USERNAME=
- MS_PASSWORD=
# - TOKEN=
- MS_USERNAME=${MS_USERNAME}
- MS_PASSWORD=${MS_PASSWORD}
- TG_CHAT_ID=${TG_CHAT_ID}
- TG_TOKEN=${TG_TOKEN}
- API_KEY=${API_KEY}
# - TOKEN=${TOKEN}
volumes:
- ./data:/app/data
restart: unless-stopped