Compare commits

..
5 Commits
15 changed files with 317 additions and 119 deletions

No files matched your search

+5 -1
View File
@@ -10,4 +10,8 @@ public interface IPlugin
// Регистрация маршрутов (Minimal API) плагина после Build() // Регистрация маршрутов (Minimal API) плагина после Build()
void MapEndpoints(IEndpointRouteBuilder endpoints); void MapEndpoints(IEndpointRouteBuilder endpoints);
}
// Инвалидация или обновление внутренних кэшей плагина.
Task InvalidateCacheAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
+15 -2
View File
@@ -47,6 +47,10 @@
- `POST /api/proxy/events/search` — получить расписание; - `POST /api/proxy/events/search` — получить расписание;
- `POST /api/proxy/rooms/search` — искать аудитории. - `POST /api/proxy/rooms/search` — искать аудитории.
В ответе `POST /api/proxy/events/search` ссылки на подключение к занятиям защищены. Если `customLocation` содержит абсолютный URI отдельно, внутри текста или Markdown-разметки, пользователь без активной Microsoft-сессии получит значение `авторизуйтесь для просмотра` вместо всего содержимого поля. После входа через Microsoft API возвращает исходное значение. `X-Api-Key` не открывает доступ к ссылкам.
Фильтрация реализована в `ScheduleLocationFilter`. Плагины могут вызывать этот общий helper для своих JSON-ответов, не дублируя проверку авторизации и обработку `customLocation`.
Если `Modeus` отвечает ошибкой, сервис возвращает соответствующий HTTP-статус и текст с префиксом `Proxied Modeus:`. Если `Modeus` отвечает ошибкой, сервис возвращает соответствующий HTTP-статус и текст с префиксом `Proxied Modeus:`.
### 2. Работа с расписанием ### 2. Работа с расписанием
@@ -82,13 +86,18 @@
Обновление выполняет Quartz job [UpdateEmployeesJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateEmployeesJob.cs): Обновление выполняет Quartz job [UpdateEmployeesJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateEmployeesJob.cs):
- ходит в `Modeus`; - ходит в `Modeus`;
- загружает большой список сотрудников; - загружает список людей из Modeus и проверяет полноту результата;
- сохраняет общий индекс людей с признаками студента и сотрудника в `data/people.json`;
- группирует записи по ФИО; - группирует записи по ФИО;
- сохраняет результат на диск; - сохраняет результат на диск;
- после успешного обновления предлагает всем плагинам инвалидировать свои кэши;
- повторяет попытки при временных сбоях. - повторяет попытки при временных сбоях.
Если сервис ещё не инициализирован, `GET /api/schedule/searchemployee` возвращает `503`. Если сервис ещё не инициализирован, `GET /api/schedule/searchemployee` возвращает `503`.
`ModeusPeopleService` предоставляет плагинам последний полный снимок людей и восстанавливает его при запуске. Новая версия файла заменяет предыдущую атомарно;
ошибка загрузки или записи сохраняет рабочий снимок. Для всего прохода страниц выделено пять минут. Контракт поиска преподавателей не меняется, студенты в его ответ не добавляются.
### 5. JWT lifecycle ### 5. JWT lifecycle
Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateJWTJob.cs): Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateJWTJob.cs):
@@ -127,7 +136,11 @@ Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/
На старте хост ищет все `*.plugin.dll` внутри `data/Plugins` и загружает найденные сборки как плагины. Плагин должен реализовывать интерфейс [IPlugin](/home/serega404/Рабочий стол/SfeduSchedule/ModeusSchedule.Abstractions/IPlugin.cs), чтобы: На старте хост ищет все `*.plugin.dll` внутри `data/Plugins` и загружает найденные сборки как плагины. Плагин должен реализовывать интерфейс [IPlugin](/home/serega404/Рабочий стол/SfeduSchedule/ModeusSchedule.Abstractions/IPlugin.cs), чтобы:
- зарегистрировать свои сервисы в DI через `ConfigureServices`; - зарегистрировать свои сервисы в DI через `ConfigureServices`;
- добавить minimal API маршруты через `MapEndpoints`. - добавить minimal API маршруты через `MapEndpoints`;
- опционально обновить или очистить свои кэши через `InvalidateCacheAsync`.
`InvalidateCacheAsync` имеет default-реализацию, поэтому плагины, собранные до добавления этого метода,
продолжают загружаться. Ошибка инвалидации одного плагина логируется и не мешает вызвать остальные.
MVC-контроллеры из плагинов тоже подключаются автоматически через `ApplicationPart`. MVC-контроллеры из плагинов тоже подключаются автоматически через `ApplicationPart`.
+2 -1
View File
@@ -38,6 +38,7 @@ public class ProxyController(ModeusService modeusService, ILogger<ProxyControlle
"Proxied Modeus: " + e.Message); "Proxied Modeus: " + e.Message);
} }
schedule = await ScheduleLocationFilter.FilterAsync(schedule, HttpContext);
return Ok(schedule); return Ok(schedule);
} }
@@ -67,4 +68,4 @@ public class ProxyController(ModeusService modeusService, ILogger<ProxyControlle
return Ok(rooms); return Ok(rooms);
} }
} }
+20 -13
View File
@@ -4,19 +4,18 @@ using SfeduSchedule.Services;
namespace SfeduSchedule.Jobs; namespace SfeduSchedule.Jobs;
// TODO: Обновляет список сотрудников из modeus и сохраняет в локальный файл
// TODO: Нужно вынести функционал обновления сюда, а в сервисе оставить только загрузку с диска
// TODO: Нужно настроить выполнение задачи на часов 10 утра каждый день
// TODO: Нужно обработать событие когда данные о сотрудниках запрашиваются до первого обновления
[DisallowConcurrentExecution]
public class UpdateEmployeesJob( public class UpdateEmployeesJob(
ILogger<UpdateEmployeesJob> logger, ILogger<UpdateEmployeesJob> logger,
ModeusEmployeeService employeeService, ModeusEmployeeService employeeService,
ModeusService modeusService) : IJob ModeusService modeusService,
ModeusPeopleService peopleService,
PluginCacheInvalidator pluginCacheInvalidator) : IJob
{ {
private const int MaxAttempts = 5; // Максимальное число попыток private const int MaxAttempts = 5; // Максимальное число попыток
private const int DelaySeconds = 50; // Задержка между попытками в секундах private const int DelaySeconds = 50; // Задержка между попытками в секундах
private const int TimeoutSeconds = 60; // Таймаут для каждого запроса в секундах private const int TimeoutSeconds = 300; // Таймаут полной загрузки всех страниц в секундах
public async Task Execute(IJobExecutionContext jobContext) public async Task Execute(IJobExecutionContext jobContext)
{ {
@@ -28,16 +27,18 @@ public class UpdateEmployeesJob(
logger.LogInformation("Попытка {Attempt}/{MaxAttempts} получения списка сотрудников из Modeus", attempt, logger.LogInformation("Попытка {Attempt}/{MaxAttempts} получения списка сотрудников из Modeus", attempt,
MaxAttempts); MaxAttempts);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TimeoutSeconds)); using var cts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds));
var employees = await modeusService.GetEmployeesAsync(cts.Token); var people = await modeusService.GetPeopleAsync(cts.Token);
var employees = ModeusService.GetEmployees(people);
if (employees.Count == 0) if (employees.Count == 0)
{ {
logger.LogWarningHere("Не удалось получить список сотрудников из Modeus."); logger.LogWarningHere("Не удалось получить список сотрудников из Modeus.");
if (attempt == MaxAttempts) if (attempt == MaxAttempts)
{ {
logger.LogError("Достигнуто максимальное число попыток получения JWT"); logger.LogError("Достигнуто максимальное число попыток загрузки людей Modeus");
return; return;
} }
@@ -45,18 +46,24 @@ public class UpdateEmployeesJob(
continue; continue;
} }
await peopleService.SetAsync(people, cts.Token);
await employeeService.SetEmployees(employees); await employeeService.SetEmployees(employees);
logger.LogInformationHere($"Получено {employees.Count} сотрудников из Modeus."); logger.LogInformationHere($"Получено {employees.Count} сотрудников из Modeus.");
await pluginCacheInvalidator.InvalidateAsync(jobContext.CancellationToken);
return; return;
} }
catch (OperationCanceledException) when (jobContext.CancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) catch (OperationCanceledException ex)
{ {
logger.LogWarning(ex, "Таймаут при получении JWT (попытка {Attempt}/{MaxAttempts})", attempt, logger.LogWarning(ex, "Таймаут при загрузке людей Modeus (попытка {Attempt}/{MaxAttempts})", attempt,
MaxAttempts); MaxAttempts);
if (attempt == MaxAttempts) if (attempt == MaxAttempts)
{ {
logger.LogError("Достигнут лимит по таймаутам при запросе JWT"); logger.LogError("Достигнут лимит по таймаутам при загрузке людей Modeus");
return; return;
} }
@@ -68,11 +75,11 @@ public class UpdateEmployeesJob(
if (attempt == MaxAttempts) if (attempt == MaxAttempts)
{ {
logger.LogError("Достигнуто максимальное число попыток из-за ошибок при запросе JWT"); logger.LogError("Достигнуто максимальное число попыток из-за ошибок при загрузке людей Modeus");
return; return;
} }
await Task.Delay(TimeSpan.FromSeconds(DelaySeconds), jobContext.CancellationToken); await Task.Delay(TimeSpan.FromSeconds(DelaySeconds), jobContext.CancellationToken);
} }
} }
} }
+12 -3
View File
@@ -46,11 +46,20 @@ public static class PluginLoader
public sealed class PluginLoadContext(string pluginMainAssemblyPath) : AssemblyLoadContext(true) public sealed class PluginLoadContext(string pluginMainAssemblyPath) : AssemblyLoadContext(true)
{ {
private readonly AssemblyDependencyResolver _resolver = new(pluginMainAssemblyPath); private readonly AssemblyDependencyResolver _resolver = new(pluginMainAssemblyPath);
private static readonly HashSet<string> SharedAssemblyNames =
[
typeof(IPlugin).Assembly.GetName().Name!,
typeof(PluginLoader).Assembly.GetName().Name!
];
// Разрешаем управляемые зависимости плагина из его папки. // Контракт плагина и API хоста должны иметь одинаковую идентичность типов в обоих контекстах загрузки.
// Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, ModeusSchedule.Abstractions). // Поэтому их всегда берём из Default ALC, даже если копии DLL лежат рядом с плагином.
protected override Assembly? Load(AssemblyName assemblyName) protected override Assembly? Load(AssemblyName assemblyName)
{ {
if (assemblyName.Name != null && SharedAssemblyNames.Contains(assemblyName.Name))
return null;
// Остальные управляемые зависимости разрешаем из папки плагина.
var path = _resolver.ResolveAssemblyToPath(assemblyName); var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is null ? null : LoadFromAssemblyPath(path); return path is null ? null : LoadFromAssemblyPath(path);
} }
@@ -61,4 +70,4 @@ public sealed class PluginLoadContext(string pluginMainAssemblyPath) : AssemblyL
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path); return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
} }
} }
+6
View File
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using ModeusSchedule.Abstractions;
using Prometheus; using Prometheus;
using Quartz; using Quartz;
using Quartz.Listener; using Quartz.Listener;
@@ -84,6 +85,8 @@ builder.Services.AddSingleton<ModeusHttpClient>();
builder.Services.AddSingleton<ModeusEmployeeService>(); builder.Services.AddSingleton<ModeusEmployeeService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ModeusEmployeeService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ModeusEmployeeService>());
builder.Services.AddSingleton<ModeusService>(); builder.Services.AddSingleton<ModeusService>();
builder.Services.AddSingleton<ModeusPeopleService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ModeusPeopleService>());
builder.Services.AddHttpClient("authClient"); builder.Services.AddHttpClient("authClient");
builder.Services.AddAuthentication() builder.Services.AddAuthentication()
@@ -133,11 +136,14 @@ foreach (var p in loadedPlugins)
// DI из плагина // DI из плагина
p.Instance.ConfigureServices(builder.Services); p.Instance.ConfigureServices(builder.Services);
builder.Services.AddSingleton<IPlugin>(p.Instance);
// Подключаем контроллеры плагина // Подключаем контроллеры плагина
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly)); mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
} }
builder.Services.AddSingleton<PluginCacheInvalidator>();
var updateJwtJob = new JobKey("UpdateJWTJob"); var updateJwtJob = new JobKey("UpdateJWTJob");
builder.Services.AddQuartz(q => builder.Services.AddQuartz(q =>
+28
View File
@@ -0,0 +1,28 @@
using System.Text.Json;
namespace SfeduSchedule.Services;
public static class AtomicJsonFile
{
public static async Task WriteAsync<T>(string path, T value, CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(stream, value, cancellationToken: cancellationToken);
await stream.FlushAsync(cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(temporaryPath, path, overwrite: true);
}
finally
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
}
+2 -2
View File
@@ -101,7 +101,7 @@ public class ModeusHttpClient
return content; return content;
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
_logger.LogErrorHere(ex, "SearchPersonAsync: Deserialization failed."); _logger.LogErrorHere(ex, "SearchPersonAsync: Deserialization failed.");
} }
@@ -122,4 +122,4 @@ public class ModeusHttpClient
return await response.Content.ReadAsStringAsync(); return await response.Content.ReadAsStringAsync();
} }
} }
@@ -0,0 +1,60 @@
using System.Collections.Immutable;
using System.Text.Json;
namespace SfeduSchedule.Services;
public sealed record ModeusPerson(string PersonId, string FullName, bool IsStudent, bool IsEmployee);
public sealed record ModeusPeopleSnapshot(DateTimeOffset UpdatedAt, ImmutableArray<ModeusPerson> Persons);
/// <summary>Последний полностью загруженный список людей Modeus. Не публикуется через API.</summary>
public sealed class ModeusPeopleService(ILogger<ModeusPeopleService> logger) : IHostedService
{
private readonly string _path = Path.Combine(AppContext.BaseDirectory, AppConsts.DataFolderName, "people.json");
private readonly SemaphoreSlim _writeLock = new(1, 1);
private ModeusPeopleSnapshot? _snapshot;
public ModeusPeopleSnapshot? Snapshot => Volatile.Read(ref _snapshot);
public async Task SetAsync(ModeusPeopleSnapshot snapshot, CancellationToken cancellationToken)
{
Validate(snapshot);
await _writeLock.WaitAsync(cancellationToken);
try
{
await AtomicJsonFile.WriteAsync(_path, snapshot, cancellationToken);
Volatile.Write(ref _snapshot, snapshot);
}
finally
{
_writeLock.Release();
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_path)) return;
try
{
await using var stream = File.OpenRead(_path);
var snapshot = await JsonSerializer.DeserializeAsync<ModeusPeopleSnapshot>(stream,
cancellationToken: cancellationToken);
Validate(snapshot);
Volatile.Write(ref _snapshot, snapshot);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Не удалось восстановить индекс людей Modeus; ожидается полная загрузка.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static void Validate(ModeusPeopleSnapshot? snapshot)
{
if (snapshot is null || snapshot.UpdatedAt == default || snapshot.Persons.IsDefaultOrEmpty ||
snapshot.Persons.Any(p => p is null || !Guid.TryParse(p.PersonId, out _)) ||
snapshot.Persons.Select(p => p.PersonId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != snapshot.Persons.Length)
throw new InvalidDataException("Неполный или некорректный индекс людей Modeus.");
}
}
+60 -86
View File
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using Ical.Net; using Ical.Net;
@@ -185,98 +186,71 @@ public class ModeusService(
return serializedCalendar; return serializedCalendar;
} }
public async Task<Dictionary<string, (string, List<string>)>> GetEmployeesAsync(CancellationToken cancellationToken = default) /// <summary>Получить полный список людей одним проходом всех страниц Modeus.</summary>
public async Task<ModeusPeopleSnapshot> GetPeopleAsync(CancellationToken cancellationToken = default)
{ {
var searchPersonResponse = const int pageSize = 1000;
await modeusHttpClient.SearchPersonAsync(new ModeusSearchPersonRequest() { Size = 38000 }, cancellationToken); var persons = new Dictionary<string, SfeduSchedule.DTO.Responses.Person>(StringComparer.OrdinalIgnoreCase);
if (searchPersonResponse == null) var students = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var employees = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int? expectedCount = null;
int? expectedPages = null;
// Modeus нумерует страницы с 1; page=0 является повтором первой страницы.
for (var page = 1; ; page++)
{ {
logger.LogErrorHere("persons is null"); var response = await modeusHttpClient.SearchPersonAsync(
return []; new ModeusSearchPersonRequest(Size: pageSize, Page: page), cancellationToken)
?? throw new HttpRequestException($"Не удалось загрузить страницу {page} людей Modeus.");
if (response.Page is null || response.Embedded?.Persons is null ||
response.Embedded.Students is null || response.Embedded.Employees is null ||
response.Page.Number != page || response.Page.TotalElements is not > 0 ||
response.Page.TotalPages is not > 0 or > 10000)
throw new InvalidDataException("Некорректная пагинация или неполный ответ поиска людей Modeus.");
expectedCount ??= response.Page.TotalElements;
expectedPages ??= response.Page.TotalPages;
if (expectedCount != response.Page.TotalElements || expectedPages != response.Page.TotalPages)
throw new InvalidDataException("Список людей Modeus изменился во время загрузки; требуется повтор.");
var countBefore = persons.Count;
foreach (var person in response.Embedded.Persons)
{
if (person is null || !Guid.TryParse(person.Id, out _))
throw new InvalidDataException("Некорректный personId в ответе Modeus.");
persons[person.Id] = person;
}
foreach (var student in response.Embedded.Students)
if (!string.IsNullOrWhiteSpace(student?.PersonId)) students.Add(student.PersonId);
foreach (var employee in response.Embedded.Employees)
if (!string.IsNullOrWhiteSpace(employee?.PersonId)) employees.Add(employee.PersonId);
if (persons.Count == countBefore)
throw new InvalidDataException("Пустая или повторяющаяся страница людей Modeus.");
if (page >= expectedPages) break;
} }
var persons = searchPersonResponse.Embedded.Persons; if (persons.Count != expectedCount || students.Any(id => !persons.ContainsKey(id)) ||
var employees = searchPersonResponse.Embedded.Employees; employees.Any(id => !persons.ContainsKey(id)))
throw new InvalidDataException("Не удалось получить полный список людей Modeus.");
var stopwatch = Stopwatch.StartNew(); return new ModeusPeopleSnapshot(DateTimeOffset.UtcNow,
var personsById = new Dictionary<string, SfeduSchedule.DTO.Responses.Person>(); persons.Values.Select(p => new ModeusPerson(p.Id, (p.FullName ?? string.Empty).Trim(),
foreach (var p in persons) students.Contains(p.Id), employees.Contains(p.Id))).ToImmutableArray());
}
public async Task<Dictionary<string, (string, List<string>)>> GetEmployeesAsync(CancellationToken cancellationToken = default)
=> GetEmployees(await GetPeopleAsync(cancellationToken));
public static Dictionary<string, (string, List<string>)> GetEmployees(ModeusPeopleSnapshot snapshot)
{
var employees = new Dictionary<string, (string, List<string>)>(StringComparer.Ordinal);
foreach (var person in snapshot.Persons.Where(p => p.IsEmployee))
{ {
var id = p?.Id; var name = string.IsNullOrWhiteSpace(person.FullName) ? person.PersonId : person.FullName;
if (!string.IsNullOrEmpty(id)) employees.TryAdd(name, (person.PersonId, []));
{
personsById[id] = p;
}
} }
return employees;
var mapMs = stopwatch.ElapsedMilliseconds;
var groupStartMs = stopwatch.ElapsedMilliseconds;
var grouped =
new Dictionary<string, (string PersonId, List<string> Positions)>(employees.Count,
StringComparer.Ordinal);
foreach (var e in employees)
{
if (e == null) continue;
var personId = e.PersonId ?? string.Empty;
if (string.IsNullOrWhiteSpace(personId)) continue;
var fullName = personId;
if (personsById.TryGetValue(personId, out var person))
{
var name = (person.FullName ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(name))
{
fullName = name;
}
}
// Отключено так как информация нигде не используется
// var position = (e.GroupName ?? string.Empty).Trim();
//
// static string FormatDateRange(string? dateIn, string? dateOut)
// {
// var start = (dateIn ?? string.Empty).Trim();
// if (string.IsNullOrEmpty(start)) start = "?";
//
// var end = (dateOut ?? string.Empty).Trim();
// if (string.IsNullOrEmpty(end)) end = "по наст.вр.";
//
// return $"{start}–{end}";
// }
//
// var dateRange = FormatDateRange(e.DateIn, e.DateOut);
// var positionWithDates = string.IsNullOrEmpty(position) ? $"({dateRange})" : $"{position} ({dateRange})"; // Место работы с датой
if (!grouped.TryGetValue(fullName, out var entry))
{
entry = (personId, new List<string>());
}
else
{
if (string.IsNullOrEmpty(entry.PersonId) || entry.PersonId == personId)
{
entry.PersonId = personId;
}
}
// if (!entry.Positions.Contains(positionWithDates))
// {
// entry.Positions.Add(positionWithDates);
// }
grouped[fullName] = entry;
}
var groupMs = stopwatch.ElapsedMilliseconds - groupStartMs;
var totalMs = stopwatch.ElapsedMilliseconds;
logger.LogInformationHere(
$"GetEmployeesAsync timing: mapPersons={mapMs}ms, groupEmployees={groupMs}ms, total={totalMs}ms");
return grouped;
} }
#region Проксирование методов из ModeusHttpClient #region Проксирование методов из ModeusHttpClient
@@ -322,4 +296,4 @@ public class ModeusService(
} }
#endregion #endregion
} }
@@ -0,0 +1,32 @@
using ModeusSchedule.Abstractions;
namespace SfeduSchedule.Services;
public sealed class PluginCacheInvalidator(
IEnumerable<IPlugin> plugins,
IServiceScopeFactory scopeFactory,
ILogger<PluginCacheInvalidator> logger)
{
public async Task InvalidateAsync(CancellationToken cancellationToken)
{
foreach (var plugin in plugins)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var scope = scopeFactory.CreateAsyncScope();
await plugin.InvalidateCacheAsync(scope.ServiceProvider, cancellationToken);
logger.LogInformation("Кэш плагина {PluginName} инвалидирован", plugin.Name);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Не удалось инвалидировать кэш плагина {PluginName}", plugin.Name);
}
}
}
}
@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace SfeduSchedule.Services;
/// <summary>
/// Скрывает ссылки на подключение к занятиям от пользователей без Microsoft-сессии.
/// </summary>
public static class ScheduleLocationFilter
{
private const string AuthorizationRequiredMessage = "авторизуйтесь для просмотра";
private static readonly Regex AbsoluteUriPattern = new(
@"(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]*:[^\s<>\[\]()]+",
RegexOptions.CultureInvariant);
/// <summary>
/// Возвращает исходное расписание авторизованному пользователю или заменяет значение
/// <c>customLocation</c> безопасной заглушкой, если оно содержит абсолютный URI.
/// </summary>
/// <param name="schedule">JSON расписания Modeus.</param>
/// <param name="httpContext">Контекст запроса, содержащий Microsoft authentication cookie.</param>
/// <returns>Исходный либо отфильтрованный JSON; <see langword="null"/>, если расписание отсутствует.</returns>
/// <exception cref="JsonException">JSON расписания пуст или имеет некорректный формат.</exception>
public static async Task<string?> FilterAsync(string? schedule, HttpContext httpContext)
{
if (schedule == null)
return null;
// API key не даёт доступ к ссылкам: проверяем только cookie, созданную Microsoft OIDC-входом.
var authenticationResult =
await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (authenticationResult.Succeeded)
return schedule;
var root = JsonNode.Parse(schedule) ?? throw new JsonException("Schedule response is empty.");
if (root["_embedded"]?["event-locations"] is not JsonArray eventLocations)
return root.ToJsonString();
// Если ссылка окружена текстом или Markdown-разметкой, скрываем всё значение customLocation.
foreach (var eventLocation in eventLocations.OfType<JsonObject>())
{
if (eventLocation["customLocation"] is not JsonValue value ||
!value.TryGetValue<string>(out var customLocation) ||
string.IsNullOrWhiteSpace(customLocation) ||
!ContainsAbsoluteUri(customLocation))
continue;
eventLocation["customLocation"] = AuthorizationRequiredMessage;
}
return root.ToJsonString();
}
private static bool ContainsAbsoluteUri(string value)
{
return AbsoluteUriPattern.Matches(value)
.Any(match => Uri.TryCreate(match.Value, UriKind.Absolute, out _));
}
}
+6 -5
View File
@@ -14,14 +14,15 @@ services:
- API_KEY=${API_KEY} - API_KEY=${API_KEY}
# - TOKEN=${TOKEN} # - TOKEN=${TOKEN}
- AUTH_URL=${AUTH_URL} - AUTH_URL=${AUTH_URL}
- AUTH_API_KEY=${AUTH_API_KEY} - AUTH_API_KEY=${AUTH_API_KEY}
- Sentry:Dsn=${SENTRY_DSN:-} - Sentry:Dsn=${SENTRY_DSN:-}
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1} - SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1} - SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
- TZ=Europe/Moscow
volumes: volumes:
- data:/app/data - data:/app/data
restart: always restart: always
image: git.zetcraft.ru/serega404/sfeduschedule:main image: git.zetcraft.ru/serega404/sfeduschedule:main
volumes: volumes:
data: data:
+6 -5
View File
@@ -13,13 +13,14 @@ services:
- AzureAd:CallbackPath=/signin-oidc - AzureAd:CallbackPath=/signin-oidc
- API_KEY=${API_KEY} - API_KEY=${API_KEY}
# - TOKEN=${TOKEN} # - TOKEN=${TOKEN}
- AUTH_URL=${AUTH_URL} - AUTH_URL=${AUTH_URL}
- AUTH_API_KEY=${AUTH_API_KEY} - AUTH_API_KEY=${AUTH_API_KEY}
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1} - SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-1}
- SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1} - SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-1}
- TZ=Europe/Moscow
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
build: build:
context: ./SfeduSchedule context: ./SfeduSchedule
dockerfile: Dockerfile dockerfile: Dockerfile