Compare commits

...

50 Commits

Author SHA1 Message Date
95692a6a1f build: update ref
All checks were successful
.NET Test Pipeline / build (push) Successful in 2m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m55s
2025-03-11 09:37:58 +03:00
13eb3c0033 fix: remove convert universal time
All checks were successful
.NET Test Pipeline / build (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m30s
2025-02-14 17:47:56 +03:00
f6d1543108 docs: fix code character
Some checks failed
.NET Test Pipeline / build (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Has been cancelled
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-13 16:40:33 +03:00
a4721c9739 build: add configuration Release
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
.NET Test Pipeline / build (push) Successful in 1m51s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2025-02-12 09:35:55 +03:00
46bbc34956 refactor: fix error WHITESPACE and FINALNEWLINE
All checks were successful
.NET Test Pipeline / build (pull_request) Successful in 1m37s
.NET Test Pipeline / build (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m18s
2025-02-11 17:13:13 +03:00
047ccfa754 fix: correct calculate next occurrence
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 44s
2025-02-11 16:35:56 +03:00
b0d9a67c1c refacotr: clean code 2025-02-11 15:36:55 +03:00
3eb043b24c build: fix code style with CRLF
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 42s
2025-02-11 15:29:43 +03:00
4cd476764d build: fix secrets
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 43s
2025-02-11 15:25:47 +03:00
90b4662dda Release 1.0.0
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 8s
2025-02-11 15:16:51 +03:00
e7edc79ebc build: instead build run analyze 2025-02-11 15:04:38 +03:00
aabeed0aa5 feat: add backend version to swagger 2025-02-10 16:07:51 +03:00
e79ec360ea Merge branch 'release/v1.0.0' of https://git.winsomnia.net/Winsomnia/MireaBackend into release/v1.0.0
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m36s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m37s
2025-02-06 16:29:48 +03:00
31c1d2804d fix: hotfix calculate next run time 2025-02-06 16:27:20 +03:00
ea4c8b61e0 refactor: use thread pool instead task
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m31s
2025-02-03 11:25:39 +03:00
b40e394bcf fix: System.ObjectDisposedException for db context into sync secrvice
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m45s
2025-02-03 10:55:47 +03:00
885b937b0b feat: add parsing from files
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 49s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m42s
2025-02-03 03:44:40 +03:00
dc08285ec8 feat: clear old records 2025-02-02 20:31:52 +03:00
b3a0964aac fix: correct filter data 2025-02-02 20:28:04 +03:00
7d6b21c5bb fix: move from body to query 2025-02-02 04:51:09 +03:00
93912caf01 fix: return correct value 2025-02-02 04:50:54 +03:00
c725cfed32 refactor: increase max value 2025-02-02 04:50:35 +03:00
7c7707b1e2 fix: if delay more than int set max of int 2025-02-02 04:50:04 +03:00
1687e9d89b fix: continue if in filter exist value 2025-02-02 04:49:25 +03:00
8d1b709b43 feat: add start term update and cron schedule update 2025-02-02 03:39:30 +03:00
ce6b0f2673 feat: add cron skipping date 2025-02-02 03:30:52 +03:00
16afc0bc69 feat: show enum name instead value 2025-02-02 03:29:19 +03:00
c9bc6a3565 refactor: remove "swagger" in class name 2025-02-02 03:28:24 +03:00
ad8f356fc1 fix: get non negative number 2025-02-02 01:57:08 +03:00
dda0a29300 refactor: subscribe to onChange instead of waiting for the event to be received from the manager 2025-02-01 21:23:51 +03:00
369901db78 fix: set long, because the value may be greater than int 2025-02-01 21:19:56 +03:00
a67b72b7fb refactor: rename cancellation to cancellationToken 2025-02-01 21:18:56 +03:00
2453b2bd51 build: upgrade ref 2025-02-01 20:47:25 +03:00
5870eef552 feat: add a tag schema to combine similar controllers. 2025-02-01 20:47:08 +03:00
52de98969d refactor: remove unused brackets 2025-02-01 20:45:08 +03:00
bc86e077bd refactor: move to SetupConfiguration namespace 2025-02-01 19:39:02 +03:00
03b6560bc4 feat: add lesson type controller 2025-02-01 17:08:00 +03:00
5bcb7bfbc1 feat: allow filter by lesson type 2025-02-01 17:06:02 +03:00
38fba5556f feat: add filter by type of occupation (lesson type) 2025-02-01 16:46:20 +03:00
fd26178a24 build: update ref
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m54s
2025-01-24 17:12:39 +03:00
7eb307b65e fix: return empty string if null 2025-01-24 17:10:46 +03:00
56c7196100 refactor: change const name to class with name 2025-01-24 17:10:18 +03:00
92081156cf fix: save token after update
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m13s
2024-12-28 08:34:19 +03:00
6358410f18 sec: to establish the ownership of the token for the first one who received it 2024-12-28 08:30:56 +03:00
e79ddf220f sec: set the absolute time of the token 2024-12-28 08:29:31 +03:00
c3c9844e2f refactor: improve logging 2024-12-28 08:29:06 +03:00
206720cd63 fix: add force select account 2024-12-28 08:16:00 +03:00
d9f4176aca fix: return message if 401 2024-12-28 08:15:43 +03:00
1de344ac25 refactor: to enable oauth during registration, use the appropriate controller. 2024-12-28 07:46:06 +03:00
61a11ea223 fix: return exception message if controller exception 2024-12-28 06:47:21 +03:00
67 changed files with 1219 additions and 296 deletions

View File

@ -12,7 +12,7 @@ indent_style = space
tab_width = 4 tab_width = 4
# Предпочтения для новых строк # Предпочтения для новых строк
end_of_line = crlf end_of_line = unset
insert_final_newline = false insert_final_newline = false
#### Действия кода .NET #### #### Действия кода .NET ####
@ -244,7 +244,7 @@ dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4 tab_width = 4
indent_size = 4 indent_size = 4
end_of_line = crlf end_of_line = unset
dotnet_style_coalesce_expression = true:suggestion dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion

View File

@ -1,29 +0,0 @@
name: .NET Test Pipeline
on:
pull_request:
push:
branches:
[master, 'release/*']
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build the solution
run: dotnet build --configuration Release
- name: Run tests
run: dotnet test --configuration Release --no-build --no-restore --verbosity normal

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

@ -0,0 +1,31 @@
name: .NET Test Pipeline
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarScanner for .NET 8 with pull request decoration support
uses: highbyte/sonarscan-dotnet@v2.3.3
with:
sonarProjectKey: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarProjectName: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarHostname: ${{ secrets.SONAR_HOST_URL }}
dotnetPreBuildCmd: dotnet nuget add source --name="Winsomnia" --username ${{ secrets.NUGET_USERNAME }} --password ${{ secrets.NUGET_PASSWORD }} --store-password-in-clear-text ${{ secrets.NUGET_ADDRESS }} && dotnet format --verify-no-changes --diagnostics -v diag --severity warn
dotnetTestArguments: --logger trx --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
dotnetBuildArguments: -c Release
sonarBeginArguments: /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml" -d:sonar.cs.vstest.reportsPaths="**/TestResults/*.trx"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@ -3,7 +3,7 @@
on: on:
push: push:
branches: branches:
[master, 'release/*'] [master]
jobs: jobs:
build-and-deploy: build-and-deploy:
@ -24,7 +24,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest . docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} --build-arg NUGET_ADDRESS=${{ secrets.NUGET_ADDRESS }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest docker push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
- name: Start ssh-agent - name: Start ssh-agent

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

@ -8,30 +8,30 @@ public class ScheduleRequest
/// <summary> /// <summary>
/// Gets or sets an array of group IDs. /// Gets or sets an array of group IDs.
/// </summary> /// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Groups { get; set; } = null; public int[]? Groups { get; set; } = null;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks. /// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary> /// </summary>
/// <remarks>This property can contain null.</remarks>
public bool? IsEven { get; set; } = null; public bool? IsEven { get; set; } = null;
/// <summary> /// <summary>
/// Gets or sets an array of discipline IDs. /// Gets or sets an array of discipline IDs.
/// </summary> /// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Disciplines { get; set; } = null; public int[]? Disciplines { get; set; } = null;
/// <summary> /// <summary>
/// Gets or sets an array of professor IDs. /// Gets or sets an array of professor IDs.
/// </summary> /// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Professors { get; set; } = null; public int[]? Professors { get; set; } = null;
/// <summary> /// <summary>
/// Gets or sets an array of lecture hall IDs. /// Gets or sets an array of lecture hall IDs.
/// </summary> /// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? LectureHalls { get; set; } = null; 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,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

@ -3,7 +3,7 @@
namespace Mirea.Api.Dto.Responses; namespace Mirea.Api.Dto.Responses;
/// <summary> /// <summary>
/// Represents basic information about a faculty. /// Represents information about a faculty.
/// </summary> /// </summary>
public class FacultyResponse public class FacultyResponse
{ {

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

@ -12,11 +12,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution",
.env = .env .env = .env
.gitattributes = .gitattributes .gitattributes = .gitattributes
.gitignore = .gitignore .gitignore = .gitignore
.gitea\workflows\deploy-stage.yaml = .gitea\workflows\deploy-stage.yaml .github\workflows\code-analyze.yaml = .github\workflows\code-analyze.yaml
Dockerfile = Dockerfile Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt LICENSE.txt = LICENSE.txt
README.md = README.md README.md = README.md
.gitea\workflows\test.yaml = .gitea\workflows\test.yaml .github\workflows\release-version.yml = .github\workflows\release-version.yml
EndProjectSection EndProjectSection
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
LABEL company="Winsomnia" LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net" LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app WORKDIR /app
@ -13,10 +13,14 @@ COPY . .
ARG NUGET_USERNAME ARG NUGET_USERNAME
ARG NUGET_PASSWORD ARG NUGET_PASSWORD
ARG NUGET_ADDRESS
ENV NUGET_USERNAME=$NUGET_USERNAME ENV NUGET_USERNAME=$NUGET_USERNAME
ENV NUGET_PASSWORD=$NUGET_PASSWORD ENV NUGET_PASSWORD=$NUGET_PASSWORD
ENV NUGET_ADDRESS=$NUGET_ADDRESS
RUN dotnet restore ./Backend.sln --configfile nuget.config RUN dotnet nuget add source --name="Winsomnia" --username ${NUGET_USERNAME} --store-password-in-clear-text --password ${NUGET_PASSWORD} ${NUGET_ADDRESS}
RUN dotnet restore ./Backend.sln
WORKDIR /app WORKDIR /app
WORKDIR /src WORKDIR /src
RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app

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

View File

@ -4,12 +4,7 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class ScheduleSyncManager public static class ScheduleSyncManager
{ {
public static event Action? OnUpdateIntervalRequested;
public static event Action? OnForceSyncRequested; public static event Action? OnForceSyncRequested;
public static void RequestIntervalUpdate() =>
OnUpdateIntervalRequested?.Invoke();
public static void RequestForceSync() => public static void RequestForceSync() =>
OnForceSyncRequested?.Invoke(); OnForceSyncRequested?.Invoke();
} }

View File

@ -5,8 +5,11 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync; using Mirea.Api.Endpoint.Sync;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -15,23 +18,46 @@ namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable public class ScheduleSyncService : IHostedService, IDisposable
{ {
private Timer? _timer; private Timer? _timer;
private readonly IOptionsMonitor<GeneralConfig> _generalConfigMonitor; private string _cronUpdate;
private List<ScheduleSettings.CronUpdateSkip> _cronUpdateSkip;
private readonly ILogger<ScheduleSyncService> _logger; private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new(); private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IDisposable? _onChangeUpdateCron;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider) public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{ {
_generalConfigMonitor = generalConfigMonitor;
_logger = logger; _logger = logger;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested; ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested += OnUpdateIntervalRequested; _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() private void OnForceSyncRequested()
{ {
_logger.LogInformation("It was requested to synchronize the data immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ => StopAsync(CancellationToken.None).ContinueWith(_ =>
{ {
_cancellationTokenSource = new CancellationTokenSource(); _cancellationTokenSource = new CancellationTokenSource();
@ -41,6 +67,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void OnUpdateIntervalRequested() private void OnUpdateIntervalRequested()
{ {
_logger.LogInformation("It was requested to update the time interval immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ => StopAsync(CancellationToken.None).ContinueWith(_ =>
{ {
StartAsync(CancellationToken.None); StartAsync(CancellationToken.None);
@ -49,31 +76,33 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void ScheduleNextRun() private void ScheduleNextRun()
{ {
var cronExpression = _generalConfigMonitor.CurrentValue.ScheduleSettings?.CronUpdateSchedule; if (string.IsNullOrEmpty(_cronUpdate))
if (string.IsNullOrEmpty(cronExpression))
{ {
_logger.LogWarning("Cron expression is not set. The scheduled task will not run."); _logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return; return;
} }
var nextRunTime = CronExpression.Parse(cronExpression).GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local); var expression = CronExpression.Parse(_cronUpdate);
if (!nextRunTime.HasValue) 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); _logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}",
TimeZoneInfo.Local.DisplayName);
return; return;
} }
_logger.LogInformation("Next task run in {Time}", nextRunTime.Value.ToString("G")); _logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
var delay = (nextRunTime.Value - DateTimeOffset.Now).TotalMilliseconds; var delay = (nextRunTime - DateTimeOffset.Now).TotalMilliseconds;
// The chance is small, but it's better to check // The chance is small, but it's better to check
if (delay <= 0) if (delay <= 0)
delay = 1; delay = 1;
_cancellationTokenSource = new CancellationTokenSource(); _cancellationTokenSource = new CancellationTokenSource();
_timer = new Timer(ExecuteTask, null, (int)delay, Timeout.Infinite); _timer = new Timer(ExecuteTask, null, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
} }
private async void ExecuteTask(object? state) private async void ExecuteTask(object? state)
@ -112,7 +141,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
StopAsync(CancellationToken.None).GetAwaiter().GetResult(); StopAsync(CancellationToken.None).GetAwaiter().GetResult();
_timer?.Dispose(); _timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested; ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested; _onChangeUpdateCron?.Dispose();
_cancellationTokenSource.Dispose(); _cancellationTokenSource.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);

View File

@ -65,11 +65,13 @@ public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<Cust
problemDetails.Status = StatusCodes.Status400BadRequest; problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"; problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Invalid arguments provided."; problemDetails.Title = "Invalid arguments provided.";
problemDetails.Detail = exception.Message;
break; break;
case SecurityException: case SecurityException:
problemDetails.Status = StatusCodes.Status401Unauthorized; problemDetails.Status = StatusCodes.Status401Unauthorized;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2"; problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
problemDetails.Title = "Unauthorized access."; problemDetails.Title = "Unauthorized access.";
problemDetails.Detail = exception.Message;
break; break;
case ServerUnavailableException unavailableException: case ServerUnavailableException unavailableException:
problemDetails.Status = StatusCodes.Status503ServiceUnavailable; problemDetails.Status = StatusCodes.Status503ServiceUnavailable;

View File

@ -95,7 +95,7 @@ public static class LoggerConfiguration
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent); diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString()); diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
}; };
}); });
} }

View File

@ -17,9 +17,11 @@ public static class SwaggerConfiguration
{ {
services.AddSwaggerGen(options => services.AddSwaggerGen(options =>
{ {
options.SchemaFilter<SwaggerExampleFilter>(); options.OperationFilter<TagSchemeFilter>();
options.OperationFilter<SwaggerDefaultValues>(); options.SchemaFilter<ExampleFilter>();
options.OperationFilter<DefaultValues>();
options.OperationFilter<ActionResultSchemaFilter>(); options.OperationFilter<ActionResultSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
var basePath = AppDomain.CurrentDomain.BaseDirectory; var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme

View File

@ -31,9 +31,33 @@ public class ScheduleSettings : IIsConfigured
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { } 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 required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; } public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; } public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public List<CronUpdateSkip> CronUpdateSkipDateList { get; set; } = [];
public bool IsConfigured() public bool IsConfigured()
{ {

View File

@ -4,6 +4,8 @@ using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
using System; using System;
using System.Diagnostics;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions; namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
@ -12,16 +14,14 @@ public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) :
public void Configure(SwaggerGenOptions options) public void Configure(SwaggerGenOptions options)
{ {
foreach (var description in provider.ApiVersionDescriptions) foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
} }
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
{ {
var info = new OpenApiInfo() var info = new OpenApiInfo()
{ {
Title = "MIREA Schedule Web API", Title = $"MIREA Schedule Web API ({FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion})",
Version = description.ApiVersion.ToString(), Version = description.ApiVersion.ToString(),
Description = "This API provides a convenient interface for retrieving data stored in the database. " + 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.", "Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",

View File

@ -8,7 +8,7 @@ using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions; namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter public class DefaultValues : IOperationFilter
{ {
public void Apply(OpenApiOperation operation, OperationFilterContext context) public void Apply(OpenApiOperation operation, OperationFilterContext context)
{ {
@ -23,16 +23,12 @@ public class SwaggerDefaultValues : IOperationFilter
foreach (var contentType in response.Content.Keys) foreach (var contentType in response.Content.Keys)
{ {
if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
{
response.Content.Remove(contentType); response.Content.Remove(contentType);
} }
} }
}
if (operation.Parameters == null) if (operation.Parameters == null)
{
return; return;
}
foreach (var parameter in operation.Parameters) foreach (var parameter in operation.Parameters)
{ {

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

@ -5,7 +5,7 @@ using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions; namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter public class ExampleFilter : ISchemaFilter
{ {
public void Apply(OpenApiSchema schema, SchemaFilterContext context) public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{ {

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,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Route("api/v{version:apiVersion}/Configuration/[controller]")]
[Authorize]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Tags("Configuration")]
public class ConfigurationBaseController : BaseController;

View File

@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common; using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests; using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration; using Mirea.Api.Dto.Requests.Configuration;
@ -18,6 +17,7 @@ using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings; using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.Validators; using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model; using Mirea.Api.Security.Common.Model;
using Mirea.Api.Security.Services; using Mirea.Api.Security.Services;
using MySqlConnector; using MySqlConnector;
@ -26,16 +26,20 @@ using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security; using System.Security;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading.Tasks;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions; using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy; using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration; namespace Mirea.Api.Endpoint.Controllers.SetupConfiguration;
[ApiVersion("1.0")] [ApiVersion("1.0")]
[MaintenanceModeIgnore] [MaintenanceModeIgnore]
@ -45,7 +49,7 @@ public class SetupController(
IMaintenanceModeNotConfigureService notConfigureService, IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache, IMemoryCache cache,
PasswordHashService passwordHashService, PasswordHashService passwordHashService,
IOptionsSnapshot<Admin> user) : BaseController OAuthService oAuthService) : BaseController
{ {
private const string CacheGeneralKey = "config_general"; private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin"; private const string CacheAdminKey = "config_admin";
@ -319,29 +323,54 @@ public class SetupController(
return Ok(true); return Ok(true);
} }
[HttpGet("UpdateAdminConfiguration")] [HttpGet("HandleToken")]
[TokenAuthentication] [TokenAuthentication]
public ActionResult UpdateAdminConfiguration() public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
{ {
if (string.IsNullOrEmpty(user.Value.Email)) var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
return Ok(); {
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
}, HttpContext, token);
if (!isSuccess || user == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin)) if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
{ {
admin = user.Value; admin = new Admin()
{
Email = user.Email ?? string.Empty,
Username = user.Username ?? string.Empty,
PasswordHash = string.Empty,
Salt = string.Empty,
OAuthProviders = new Dictionary<OAuthProvider, OAuthUser>
{
{provider.Value, user}
}
};
cache.Set(CacheAdminKey, admin); cache.Set(CacheAdminKey, admin);
return Ok(); return Ok();
} }
admin!.OAuthProviders = user.Value.OAuthProviders; if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
if (string.IsNullOrEmpty(admin.Email)) {
admin.Email = user.Value.Email; Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
if (string.IsNullOrEmpty(admin.Username)) Status = StatusCodes.Status409Conflict,
admin.Username = user.Value.Username; Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, user);
cache.Set(CacheAdminKey, admin); cache.Set(CacheAdminKey, admin);
return Ok(); return Ok();
} }

View File

@ -172,7 +172,6 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
/// <param name="action">The action to be performed: Login or Bind.</param> /// <param name="action">The action to be performed: Login or Bind.</param>
/// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns> /// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")] [HttpGet("HandleToken")]
[MaintenanceModeIgnore]
[BadRequestResponse] [BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action) public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action)
{ {

View File

@ -0,0 +1,219 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1.Configuration;
[ApiVersion("1.0")]
public class ScheduleController(ILogger<ScheduleController> logger, IOptionsSnapshot<GeneralConfig> config, UberDbContext dbContext, IServiceProvider provider) : ConfigurationBaseController
{
/// <summary>
/// Retrieves the cron update schedule and calculates the next scheduled tasks based on the provided depth.
/// </summary>
/// <param name="depth">The depth of the next tasks to retrieve.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
[HttpGet("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery][Range(0, 10)] int depth = 5)
{
var cronExpression = CronExpression.Parse(config.Value.ScheduleSettings!.CronUpdateSchedule);
var nextTasks = config.Value.ScheduleSettings!.CronUpdateSkipDateList.GetNextTask(cronExpression, depth);
return new CronUpdateScheduleResponse()
{
Cron = config.Value.ScheduleSettings!.CronUpdateSchedule,
NextStart = nextTasks.Select(x => DateTime.SpecifyKind(x.DateTime, DateTimeKind.Local)).ToList()
};
}
/// <summary>
/// Updates the cron update schedule with the provided cron expression.
/// </summary>
/// <param name="cron">The cron expression to set as the new schedule.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided cron expression is invalid.</exception>
[HttpPost("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery] string cron)
{
cron = cron.Trim();
if (!CronExpression.TryParse(cron, CronFormat.Standard, out _))
throw new ControllerArgumentException("Incorrect cron value.");
if (config.Value.ScheduleSettings!.CronUpdateSchedule == cron)
return CronUpdateSchedule();
config.Value.ScheduleSettings!.CronUpdateSchedule = cron;
config.Value.SaveSetting();
return CronUpdateSchedule();
}
/// <summary>
/// Retrieves the start term date from the configuration.
/// </summary>
/// <returns>Start term date.</returns>
[HttpGet("StartTerm")]
public ActionResult<DateOnly> StartTerm() =>
config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Updates the start term date in the configuration.
/// </summary>
/// <param name="startTerm">The new start term date to set.</param>
/// <param name="force">If true, forces an update by deleting all existing lessons.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the start term date is more than 6 months in the past or future.</exception>
[HttpPost("StartTerm")]
public ActionResult StartTerm([FromQuery] DateOnly startTerm, [FromQuery] bool force = false)
{
var differentByTime = DateTime.Now - startTerm.ToDateTime(new TimeOnly(0, 0, 0));
if (differentByTime > TimeSpan.FromDays(190) || differentByTime.Multiply(-1) > TimeSpan.FromDays(190))
throw new ControllerArgumentException("The semester can't start more than 6 months from now, and it can't have started more than 6 months ago either.");
config.Value.ScheduleSettings!.StartTerm = startTerm;
config.Value.SaveSetting();
if (!force)
return Ok();
logger.LogWarning("A force update is being performed at the beginning of the semester (all classes will be deleted).");
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
dbContext.SaveChanges();
return Ok();
}
/// <summary>
/// Retrieves the list of cron update skip dates filtered by the current date.
/// </summary>
/// <returns>Cron update skip dates.</returns>
[HttpGet("CronUpdateSkip")]
public ActionResult<List<CronUpdateSkip>> CronUpdateSkip()
{
var generalConfig = config.Value;
generalConfig.ScheduleSettings!.CronUpdateSkipDateList =
generalConfig.ScheduleSettings.CronUpdateSkipDateList.Filter();
generalConfig.SaveSetting();
return generalConfig.ScheduleSettings!.CronUpdateSkipDateList
.ConvertToDto();
}
/// <summary>
/// Updates the list of cron update skip dates in the configuration.
/// </summary>
/// <param name="cronUpdateDate">The list of cron update skip dates to set.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided list of cron update skip dates is invalid.</exception>
[HttpPost("CronUpdateSkip")]
public ActionResult CronUpdateSkip([FromBody] List<CronUpdateSkip> cronUpdateDate)
{
List<ScheduleSettings.CronUpdateSkip> result;
try
{
result = cronUpdateDate.ConvertFromDto();
}
catch (ArgumentException ex)
{
throw new ControllerArgumentException(ex.Message);
}
config.Value.ScheduleSettings!.CronUpdateSkipDateList = result.Filter();
config.Value.SaveSetting();
return Ok();
}
/// <summary>
/// Uploads schedule files and initiates synchronization.
/// </summary>
/// <param name="files">The list of schedule files to upload.</param>
/// <param name="defaultCampus">The default campus for each uploaded file. Must match the number of files.</param>
/// <param name="force">If true, removes all existing lessons before synchronization. Default is false.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">
/// Thrown if:
/// - No files are provided.
/// - The number of default campuses does not match the number of files.
/// - Any default campus is null or empty.
/// </exception>
[HttpPost("Upload")]
public async Task<ActionResult> UploadScheduleFiles(List<IFormFile>? files, [FromQuery] string[]? defaultCampus, [FromQuery] bool force = false)
{
if (files == null || files.Count == 0)
throw new ControllerArgumentException("No files were found.");
if (defaultCampus == null || files.Count != defaultCampus.Length)
throw new ControllerArgumentException("No default campuses are specified for the file.");
if (defaultCampus.Any(string.IsNullOrEmpty))
throw new ControllerArgumentException("Each file should have a default campus.");
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
if (!Directory.Exists(tempDirectory))
Directory.CreateDirectory(tempDirectory);
List<(string, string)> filePaths = [];
for (var i = 0; i < files.Count; i++)
{
if (files[i].Length <= 0)
continue;
var filePath = Path.Combine(tempDirectory, files[i].FileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await files[i].CopyToAsync(stream);
filePaths.Add((filePath, defaultCampus[i]));
}
if (force)
{
dbContext.Lessons.RemoveRange(await dbContext.Lessons.ToListAsync());
await dbContext.SaveChangesAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
ThreadPool.QueueUserWorkItem(async void (_) =>
{
try
{
using var scope = scopeFactory.CreateScope();
var sync = (ScheduleSynchronizer)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, typeof(ScheduleSynchronizer));
await sync.StartSync(filePaths, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
return Ok();
}
}

View File

@ -48,7 +48,8 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
DisciplineIds = request.Disciplines, DisciplineIds = request.Disciplines,
GroupIds = request.Groups, GroupIds = request.Groups,
LectureHallIds = request.LectureHalls, LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList(); })).Schedules.ToList();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial; ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

View File

@ -0,0 +1,43 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LessonTypeController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of type of occupation.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of type of occupation.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<LessonTypeResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetTypeOfOccupationListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.TypeOfOccupations
.Select(f => new LessonTypeResponse()
{
Id = f.Id,
Name = f.Name
})
);
}
}

View File

@ -51,7 +51,8 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
if ((request.Groups == null || request.Groups.Length == 0) && if ((request.Groups == null || request.Groups.Length == 0) &&
(request.Disciplines == null || request.Disciplines.Length == 0) && (request.Disciplines == null || request.Disciplines.Length == 0) &&
(request.Professors == null || request.Professors.Length == 0) && (request.Professors == null || request.Professors.Length == 0) &&
(request.LectureHalls == null || request.LectureHalls.Length == 0)) (request.LectureHalls == null || request.LectureHalls.Length == 0) &&
(request.LessonType == null || request.LessonType.Length == 0))
{ {
throw new ControllerArgumentException("At least one of the arguments must be selected." throw new ControllerArgumentException("At least one of the arguments must be selected."
+ (request.IsEven.HasValue + (request.IsEven.HasValue
@ -65,7 +66,8 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
DisciplineIds = request.Disciplines, DisciplineIds = request.Disciplines,
GroupIds = request.Groups, GroupIds = request.Groups,
LectureHallIds = request.LectureHalls, LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList(); })).Schedules.ToList();
if (result.Count == 0) if (result.Count == 0)
@ -101,6 +103,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param> /// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param> /// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param> /// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified group.</returns> /// <returns>A response containing schedules for the specified group.</returns>
[HttpGet("GetByGroup/{id:int}")] [HttpGet("GetByGroup/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
@ -110,14 +113,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null, [FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null, [FromQuery] int[]? disciplines = null,
[FromQuery] int[]? professors = null, [FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) => [FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest await Get(new ScheduleRequest
{ {
Disciplines = disciplines, Disciplines = disciplines,
IsEven = isEven, IsEven = isEven,
Groups = [id], Groups = [id],
Professors = professors, Professors = professors,
LectureHalls = lectureHalls LectureHalls = lectureHalls,
LessonType = lessonType
}); });
/// <summary> /// <summary>
@ -128,6 +133,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param> /// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="groups">An array of group IDs.</param> /// <param name="groups">An array of group IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param> /// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified professor.</returns> /// <returns>A response containing schedules for the specified professor.</returns>
[HttpGet("GetByProfessor/{id:int}")] [HttpGet("GetByProfessor/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
@ -137,14 +143,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null, [FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null, [FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null, [FromQuery] int[]? groups = null,
[FromQuery] int[]? lectureHalls = null) => [FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest await Get(new ScheduleRequest
{ {
Disciplines = disciplines, Disciplines = disciplines,
IsEven = isEven, IsEven = isEven,
Groups = groups, Groups = groups,
Professors = [id], Professors = [id],
LectureHalls = lectureHalls LectureHalls = lectureHalls,
LessonType = lessonType
}); });
/// <summary> /// <summary>
@ -155,6 +163,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param> /// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param> /// <param name="professors">An array of professor IDs.</param>
/// <param name="groups">An array of group IDs.</param> /// <param name="groups">An array of group IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified lecture hall.</returns> /// <returns>A response containing schedules for the specified lecture hall.</returns>
[HttpGet("GetByLectureHall/{id:int}")] [HttpGet("GetByLectureHall/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
@ -164,14 +173,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null, [FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null, [FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null, [FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null) => [FromQuery] int[]? professors = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest await Get(new ScheduleRequest
{ {
Disciplines = disciplines, Disciplines = disciplines,
IsEven = isEven, IsEven = isEven,
Groups = groups, Groups = groups,
Professors = professors, Professors = professors,
LectureHalls = [id] LectureHalls = [id],
LessonType = lessonType
}); });
/// <summary> /// <summary>
@ -182,6 +193,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="groups">An array of group IDs.</param> /// <param name="groups">An array of group IDs.</param>
/// <param name="professors">An array of professor IDs.</param> /// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param> /// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified discipline.</returns> /// <returns>A response containing schedules for the specified discipline.</returns>
[HttpGet("GetByDiscipline/{id:int}")] [HttpGet("GetByDiscipline/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
@ -191,13 +203,15 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null, [FromQuery] bool? isEven = null,
[FromQuery] int[]? groups = null, [FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null, [FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) => [FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest await Get(new ScheduleRequest
{ {
Disciplines = [id], Disciplines = [id],
IsEven = isEven, IsEven = isEven,
Groups = groups, Groups = groups,
Professors = professors, Professors = professors,
LectureHalls = lectureHalls LectureHalls = lectureHalls,
LessonType = lessonType
}); });
} }

View File

@ -5,62 +5,65 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0-rc6</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.2.6</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.2.6</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName> <AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<InvariantGlobalization>false</InvariantGlobalization> <InvariantGlobalization>false</InvariantGlobalization>
<UserSecretsId>65cea060-88bf-4e35-9cfb-18fc996a8f05</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
<SignAssembly>False</SignAssembly>
<GenerateDocumentationFile>True</GenerateDocumentationFile> <GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>docs.xml</DocumentationFile> <DocumentationFile>docs.xml</DocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn> <NoWarn>$(NoWarn);1591</NoWarn>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.System" Version="9.0.0" />
<PackageReference Include="Cronos" Version="0.9.0" /> <PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="EPPlus" Version="7.5.2" /> <PackageReference Include="EPPlus" Version="7.6.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" /> <PackageReference Include="EPPlus.System.Drawing" Version="8.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.13" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.12.0" /> <PackageReference Include="Microsoft.Build.Framework" Version="17.13.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.11"> <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.5" /> <PackageReference Include="Microsoft.IdentityModel.Protocols" Version="8.6.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.6.1" />
<PackageReference Include="Mirea.Tools.Schedule.Parser" Version="1.2.5" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.6" />
<PackageReference Include="QRCoder" Version="1.6.0" /> <PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" /> <PackageReference Include="StackExchange.Redis" Version="2.8.31" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.CodeDom" Version="[8.0.0, 9.0.0)" /> <PackageReference Include="System.CodeDom" Version="9.0.2" />
<PackageReference Include="System.Composition" Version="[8.0.0, 9.0.0)" /> <PackageReference Include="System.Composition" Version="9.0.2" />
<PackageReference Include="System.Composition.TypedParts" Version="[8.0.0, 9.0.0)" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.11" /> <PackageReference Include="System.Drawing.Common" Version="9.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" /> <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" />
<PackageReference Include="System.Threading.Channels" Version="[8.0.0, 9.0.0)" /> <PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.2" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="8.103.6.4" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.2" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.2" />
<PackageReference Include="System.Threading.Channels" Version="9.0.2" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="9.103.7.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,9 +6,11 @@ using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Endpoint.Common.Interfaces; using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model; using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Sync.Common; using Mirea.Api.Endpoint.Sync.Common;
using Mirea.Tools.Schedule.Parser.Domain;
using Mirea.Tools.Schedule.WebParser; using Mirea.Tools.Schedule.WebParser;
using Mirea.Tools.Schedule.WebParser.Common.Domain; using Mirea.Tools.Schedule.WebParser.Common.Domain;
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
@ -220,35 +222,15 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken); await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
} }
public async Task StartSync(CancellationToken cancellationToken) private async Task Sync(Func<CancellationToken, Task<List<GroupResult>>> parseDataAsync, CancellationToken cancellationToken)
{ {
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod;
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
nameof(pairPeriods),
nameof(startTerm));
return;
}
Stopwatch watch = new(); Stopwatch watch = new();
watch.Start(); watch.Start();
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try try
{ {
logger.LogDebug("Start parsing schedule"); logger.LogDebug("Start parsing schedule");
var data = await parser.ParseAsync(cancellationToken); var data = await parseDataAsync(cancellationToken);
watch.Stop(); watch.Stop();
var parsingTime = watch.ElapsedMilliseconds; var parsingTime = watch.ElapsedMilliseconds;
@ -282,11 +264,255 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "An error occurred during synchronization."); logger.LogError(ex, "An error occurred during synchronization.");
maintenanceMode.DisableMaintenanceMode();
throw; throw;
} }
finally
{
maintenanceMode.DisableMaintenanceMode();
}
} }
public async Task StartSync(CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
nameof(pairPeriods),
nameof(startTerm));
return;
}
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
await Sync(parser.ParseAsync, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(List<(string File, string Campus)> files, CancellationToken cancellationToken)
{
await Task.Yield();
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
if (pairPeriods == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} variable is not initialized.",
nameof(pairPeriods));
return;
}
try
{
Task<List<GroupResult>> ParseTask(CancellationToken ctx)
{
var mappedData = new ConcurrentBag<GroupResult>();
ParallelOptions options = new() { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.ForEach(files, options, (file) =>
{
var parser = new Tools.Schedule.Parser.Parser();
var result = ConvertToGroupResults(parser.Parse(file.File, pairPeriods), file.Campus);
foreach (var item in result) mappedData.Add(item);
});
return Task.FromResult(mappedData.ToList());
}
await Sync(ParseTask, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
private static List<GroupResult> ConvertToGroupResults(IEnumerable<GroupInfo> groups, string campusDefault, CancellationToken cancellationToken = default)
{
var result = new List<GroupResult>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var day in group.Days)
{
foreach (var pair in day.Lessons)
{
foreach (var lesson in pair.Value)
{
if (string.IsNullOrWhiteSpace(lesson.TypeOfOccupation))
continue;
var (weeks, isExclude) = ParseWeeks(lesson.Discipline);
var (lectureHalls, campuses) = ParseLectureHalls(lesson.LectureHall, campusDefault);
var groupResult = new GroupResult
{
Day = day.DayOfWeek,
Pair = pair.Key,
IsEven = lesson.IsEven,
Group = group.GroupName,
Discipline = NormalizeDiscipline(lesson.Discipline),
Professor = ParseProfessors(lesson.Professor),
TypeOfOccupation = lesson.TypeOfOccupation,
LectureHalls = lectureHalls,
Campuses = campuses,
SpecialWeek = weeks,
IsExclude = isExclude
};
result.Add(groupResult);
}
}
}
}
return result;
}
private static string[]? ParseProfessors(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace(",", " "),
@"\s+", " ").Trim();
return ProfessorFullName().Matches(normalized)
.Select(m => $"{m.Groups["surname"].Value} {m.Groups["initials"].Value}".Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
}
private static (int[]? weeks, bool? isExclude) ParseWeeks(string discipline)
{
var match = ParseSpecificWeeks().Match(discipline);
if (!match.Success) return (null, null);
var numbers = new List<int>();
var ranges = match.Groups[2].Value.Split(',');
foreach (var range in ranges)
{
if (range.Contains('-'))
{
var parts = range.Split('-');
if (int.TryParse(parts[0], out var start) &&
int.TryParse(parts[1], out var end))
{
numbers.AddRange(Enumerable.Range(start, end - start + 1));
}
}
else
if (int.TryParse(range, out var num)) numbers.Add(num);
}
return (
weeks: numbers.Distinct().OrderBy(x => x).ToArray(),
isExclude: match.Groups[1].Success
);
}
private static string NormalizeDiscipline(string input)
{
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace("\r", " "),
@"\s{2,}", " ");
normalized = Regex.Replace(normalized,
@"(\S+)\s(\S{3,})",
"$1 $2");
normalized = ParseSpecificWeeks().Replace(normalized, "");
return normalized.Trim();
}
private static (string[]? lectureHalls, string[]? campuses) ParseLectureHalls(string? input, string defaultCampus)
{
if (string.IsNullOrWhiteSpace(input))
return (null, null);
var matches = ParseLectureCampus().Matches(input);
var lectureHalls = new List<string>();
var campuses = new List<string>();
foreach (Match match in matches)
{
if (match.Groups["lectureWithCampus"].Success)
{
var raw = match.Value.Split('(');
var campus = raw.LastOrDefault()?.Trim(')').Trim();
var lecture = raw.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(campus) || string.IsNullOrEmpty(lecture))
continue;
campuses.Add(campus);
lectureHalls.Add(lecture);
}
else if (match.Groups["lecture"].Success)
{
var lecture = match.Value.Trim();
if (string.IsNullOrEmpty(lecture))
continue;
campuses.Add(defaultCampus);
lectureHalls.Add(lecture);
}
}
return (
lectureHalls: lectureHalls.ToArray(),
campuses: campuses.ToArray()
);
}
[GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")] [GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")]
private static partial Regex OnlyGroupName(); private static partial Regex OnlyGroupName();
[GeneratedRegex(@"(?<surname>[А-ЯЁ][а-яё]+(-[А-ЯЁ][а-яё]+)?)\s*(?<initials>[А-ЯЁ]\.[А-ЯЁ]?\.?)?", RegexOptions.IgnorePatternWhitespace)]
private static partial Regex ProfessorFullName();
[GeneratedRegex(@"([Кк]р\.?)?\s*((\d+-\d+|\d+)(,\s*\d+(-\d+)?)*)\s*[Нн]\.?", RegexOptions.IgnoreCase, "ru-RU")]
private static partial Regex ParseSpecificWeeks();
[GeneratedRegex(@"(?<lectureWithCampus>[^,.\n]+\s?\([А-Яа-яA-Za-z]+-?\d+\))|(?<lecture>[^,.\n]+)")]
private static partial Regex ParseLectureCampus();
} }

View File

@ -86,12 +86,11 @@ To set up the `redirect URL` when registering and logging in using OAuth 2, use
**Where:** **Where:**
- `{schema}` is the protocol you are using (`http` or `https'). - `{schema}` is the protocol you are using (`http` or `https`).
- `{domain}` is your domain (for example, `mydomain.com` or IP address). - `{domain}` is your domain (for example, `mydomain.com` or IP address).
- `{portString}` is a port string that is only needed if your application is running on a nonstandard port (for - `{portString}` is a port string that is only needed if your application is running on a non-standard port (for
example, `:8080`). If you use standard ports (`80` for `http` and `443` for `https`), this parameter can be omitted. example, `:8080`). If you use standard ports (`80` for `http` and `443` for `https`), this parameter can be omitted.
- `{ACTUAL_SUB_PATH}` is the path to your API that you specify in the settings. If it ends with `/api', then don't add ` - `{ACTUAL_SUB_PATH}` is the path to your API that you specify in the settings. If it ends with `/api`, then don't add `/api` at the end of the URL.
/api` at the end of the URL.
**Examples:** **Examples:**

View File

@ -5,5 +5,8 @@ internal class OAuthUserExtension
public string? Message { get; set; } public string? Message { get; set; }
public bool IsSuccess { get; set; } public bool IsSuccess { get; set; }
public required OAuthProvider? Provider { get; set; } public required OAuthProvider? Provider { get; set; }
public string? UserAgent { get; set; } = null;
public string? Ip { get; set; } = null;
public string? Fingerprint { get; set; } = null;
public OAuthUser? User { get; set; } public OAuthUser? User { get; set; }
} }

View File

@ -28,7 +28,7 @@ internal class RequestContextInfo
UserAgent = userAgent; UserAgent = userAgent;
Fingerprint = fingerprint; Fingerprint = fingerprint;
Ip = ip; Ip = ip;
RefreshToken = context.Request.Cookies["refresh_token"] ?? string.Empty; RefreshToken = context.Request.Cookies[CookieNames.RefreshToken] ?? string.Empty;
} }
public string UserAgent { get; private set; } public string UserAgent { get; private set; }

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.1.3</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.1.3.3</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.1.3.3</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Security</AssemblyName> <AssemblyName>Mirea.Api.Security</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
@ -15,8 +15,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="[8.0.0, 9.0.0)" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="System.Memory" Version="4.6.0" /> <PackageReference Include="System.Memory" Version="4.6.0" />
</ItemGroup> </ItemGroup>

View File

@ -29,14 +29,14 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first"; private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed"; private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) => private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellationToken) =>
cache.SetAsync( cache.SetAsync(
GetAuthCacheKey(data.Fingerprint), GetAuthCacheKey(data.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(data), JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: Lifetime, slidingExpiration: Lifetime,
cancellationToken: cancellation); cancellationToken: cancellationToken);
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) => private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellationToken) =>
cache.SetAsync( cache.SetAsync(
GetFirstAuthCacheKey(requestContext.Fingerprint), GetFirstAuthCacheKey(requestContext.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext) JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
@ -46,14 +46,14 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
TwoFactorAuthenticator = data.TwoFactorAuthenticator TwoFactorAuthenticator = data.TwoFactorAuthenticator
}), }),
slidingExpiration: LifetimeFirstAuth, slidingExpiration: LifetimeFirstAuth,
cancellationToken: cancellation); cancellationToken: cancellationToken);
private Task RevokeAccessToken(string token) => private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token)); revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation) private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellationToken)
{ {
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1; var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellationToken) ?? 1;
var failedLoginCacheExpiration = TimeSpan.FromHours(1); var failedLoginCacheExpiration = TimeSpan.FromHours(1);
if (failedLoginAttemptsCount > 5) if (failedLoginAttemptsCount > 5)
@ -74,30 +74,30 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
failedLoginAttemptsCount); failedLoginAttemptsCount);
await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1, await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellation); slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellationToken);
} }
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellation) => private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellationToken) =>
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellation); cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellationToken);
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username, private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
CancellationToken cancellation = default) CancellationToken cancellationToken = default)
{ {
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) || if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) ||
user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) && user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash)) passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
{ {
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation); await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellationToken);
return; return;
} }
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellation); await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellationToken);
throw new SecurityException("Authentication failed. Please check your credentials."); throw new SecurityException("Authentication failed. Please check your credentials.");
} }
private async Task GenerateAuthTokensAsync(CookieOptions cookieOptions, HttpContext context, private async Task GenerateAuthTokensAsync(CookieOptions cookieOptions, HttpContext context,
RequestContextInfo requestContext, string userId, CancellationToken cancellation = default) RequestContextInfo requestContext, string userId, CancellationToken cancellationToken = default)
{ {
var refreshToken = GenerateRefreshToken(); var refreshToken = GenerateRefreshToken();
var (token, expireIn) = GenerateAccessToken(userId); var (token, expireIn) = GenerateAccessToken(userId);
@ -110,7 +110,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
AccessToken = token AccessToken = token
}; };
await StoreAuthTokenInCache(authToken, cancellation); await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn); cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime)); cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
@ -121,11 +121,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
} }
public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
CancellationToken cancellation = default) CancellationToken cancellationToken = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation); var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellationToken);
if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator) if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
throw new SecurityException("Session expired. Please log in again."); throw new SecurityException("Session expired. Please log in again.");
@ -147,35 +147,35 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
if (!totp.VerifyToken(code)) if (!totp.VerifyToken(code))
{ {
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation); await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellationToken);
throw new SecurityException("Invalid verification code. Please try again."); throw new SecurityException("Invalid verification code. Please try again.");
} }
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation); await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellationToken);
} }
break; break;
default: default:
throw new InvalidOperationException("Unsupported authorization method."); throw new InvalidOperationException("Unsupported authorization method.");
} }
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation); await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellationToken);
return true; return true;
} }
private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions, private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
HttpContext context, HttpContext context,
User user, User user,
CancellationToken cancellation = default) CancellationToken cancellationToken = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None) if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{ {
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation); await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellationToken);
return TwoFactorAuthenticator.None; return TwoFactorAuthenticator.None;
} }
await StoreFirstAuthTokenInCache(user, requestContext, cancellation); await StoreFirstAuthTokenInCache(user, requestContext, cancellationToken);
return user.TwoFactorAuthenticator; return user.TwoFactorAuthenticator;
} }
@ -201,20 +201,20 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
User user, User user,
string password, string password,
string username, string username,
CancellationToken cancellation = default) CancellationToken cancellationToken = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
username = username.Trim(); username = username.Trim();
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation); await VerifyUserOrThrowError(requestContext, user, password, username, cancellationToken);
return await LoginAsync(cookieOptions, context, user, cancellation); return await LoginAsync(cookieOptions, context, user, cancellationToken);
} }
public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default) public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{ {
const string defaultMessageError = "The session time has expired"; const string defaultMessageError = "The session time has expired";
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ?? var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken) ??
throw new SecurityException(defaultMessageError); throw new SecurityException(defaultMessageError);
if (authToken.RefreshToken != requestContext.RefreshToken || if (authToken.RefreshToken != requestContext.RefreshToken ||
@ -222,29 +222,21 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.Ip != requestContext.Ip) authToken.Ip != requestContext.Ip)
{ {
await RevokeAccessToken(authToken.AccessToken); await RevokeAccessToken(authToken.AccessToken);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation); await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
cookieOptions.DropCookie(context, CookieNames.AccessToken); cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken); cookieOptions.DropCookie(context, CookieNames.RefreshToken);
const string error = "Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. "; logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. " +
if (authToken.RefreshToken != requestContext.RefreshToken) "RefreshToken: {ExpectedRefreshToken} -> {RefreshToken}, " +
logger.LogWarning( "UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
error + "Ip: {ExpectedUserIp} -> {ProvidedIp}",
"Cached refresh token {ExpectedRefreshToken} does not match the provided refresh token {RefreshToken}",
authToken.UserId, authToken.UserId,
authToken.Fingerprint, authToken.Fingerprint,
authToken.RefreshToken, authToken.RefreshToken,
requestContext.RefreshToken); requestContext.RefreshToken,
else
logger.LogWarning(
error +
"User-Agent {ExpectedUserAgent} and IP {ExpectedUserIp} in cache do not match the provided " +
"User-Agent {ProvidedUserAgent} and IP {ProvidedIp}",
authToken.UserId,
authToken.Fingerprint,
authToken.UserAgent, authToken.UserAgent,
authToken.Ip,
requestContext.UserAgent, requestContext.UserAgent,
authToken.Ip,
requestContext.Ip); requestContext.Ip);
throw new SecurityException(defaultMessageError); throw new SecurityException(defaultMessageError);
@ -282,24 +274,24 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.AccessToken = token; authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken; authToken.RefreshToken = newRefreshToken;
await StoreAuthTokenInCache(authToken, cancellation); await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn); cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime)); cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
} }
public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default) public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{ {
var requestContext = new RequestContextInfo(context, cookieOptions); var requestContext = new RequestContextInfo(context, cookieOptions);
cookieOptions.DropCookie(context, CookieNames.AccessToken); cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken); cookieOptions.DropCookie(context, CookieNames.RefreshToken);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation); var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
if (authTokenStruct == null) if (authTokenStruct == null)
return; return;
await RevokeAccessToken(authTokenStruct.AccessToken); await RevokeAccessToken(authTokenStruct.AccessToken);
await cache.RemoveAsync(requestContext.Fingerprint, cancellation); await cache.RemoveAsync(requestContext.Fingerprint, cancellationToken);
} }
} }

View File

@ -58,7 +58,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
}; };
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code, private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code,
string clientId, string secret, CancellationToken cancellation) string clientId, string secret, CancellationToken cancellationToken)
{ {
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri) var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{ {
@ -75,8 +75,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient(); using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)"); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(tokenRequest, cancellation); var response = await httpClient.SendAsync(tokenRequest, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellation); var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data); throw new HttpRequestException(data);
@ -85,7 +85,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
} }
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider, private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider,
CancellationToken cancellation) CancellationToken cancellationToken)
{ {
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
@ -97,8 +97,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient(); using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)"); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(request, cancellation); var response = await httpClient.SendAsync(request, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellation); var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data); throw new HttpRequestException(data);
@ -167,12 +167,12 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
} }
} }
private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellation) => private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellationToken) =>
cache.SetAsync( cache.SetAsync(
key, key,
JsonSerializer.SerializeToUtf8Bytes(data), JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: TimeSpan.FromMinutes(15), absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
cancellationToken: cancellation); cancellationToken: cancellationToken);
public Uri GetProviderRedirect(CookieOptions cookieOptions, HttpContext context, string redirectUri, public Uri GetProviderRedirect(CookieOptions cookieOptions, HttpContext context, string redirectUri,
@ -193,7 +193,9 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
"&response_type=code" + "&response_type=code" +
$"&redirect_uri={redirectUri}" + $"&redirect_uri={redirectUri}" +
$"&scope={ProviderData[provider].Scope}" + $"&scope={ProviderData[provider].Scope}" +
$"&state={Uri.EscapeDataString(payload + "_" + checksum)}"; $"&state={Uri.EscapeDataString(payload + "_" + checksum)}" +
"&prompt=select_account" +
"&force_confirm=true";
logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}", logger.LogInformation("Redirecting user Fingerprint: {Fingerprint} to OAuth provider {Provider} with state: {State}",
requestInfo.Fingerprint, requestInfo.Fingerprint,
@ -207,7 +209,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
[.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))]; [.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
public async Task<LoginOAuth> LoginOAuth(CookieOptions cookieOptions, HttpContext context, public async Task<LoginOAuth> LoginOAuth(CookieOptions cookieOptions, HttpContext context,
string redirectUrl, string code, string state, CancellationToken cancellation = default) string redirectUrl, string code, string state, CancellationToken cancellationToken = default)
{ {
var result = new LoginOAuth() var result = new LoginOAuth()
{ {
@ -224,7 +226,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
{ {
Message = result.ErrorMessage, Message = result.ErrorMessage,
Provider = null Provider = null
}, cancellation); }, cancellationToken);
return result; return result;
} }
@ -249,7 +251,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
result.ErrorMessage = "Invalid authorization request. Please try again later."; result.ErrorMessage = "Invalid authorization request. Please try again later.";
cacheData.Message = result.ErrorMessage; cacheData.Message = result.ErrorMessage;
await StoreOAuthUserInCache(result.Token, cacheData, cancellation); await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result; return result;
} }
@ -269,7 +271,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
checksum checksum
); );
await StoreOAuthUserInCache(result.Token, cacheData, cancellation); await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result; return result;
} }
@ -278,7 +280,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
try try
{ {
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId, accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId,
providerInfo.Secret, cancellation); providerInfo.Secret, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -286,7 +288,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
payload.Provider, payload.Provider,
checksum); checksum);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation); await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result; return result;
} }
@ -298,14 +300,14 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
try try
{ {
user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken, user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
payload.Provider, cancellation); payload.Provider, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}",
payload.Provider); payload.Provider);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation); await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result; return result;
} }
@ -321,24 +323,24 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
IsSuccess = true, IsSuccess = true,
User = user, User = user,
Provider = payload.Provider Provider = payload.Provider
}, cancellation); }, cancellationToken);
return result; return result;
} }
public async Task<(OAuthUser? User, string? Message, bool IsSuccess, OAuthProvider? Provider)> public async Task<(OAuthUser? User, string? Message, bool IsSuccess, OAuthProvider? Provider)>
GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellation = default) GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellationToken = default)
{ {
var requestInfo = new RequestContextInfo(context, cookieOptions); var requestInfo = new RequestContextInfo(context, cookieOptions);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellation); var result = await cache.GetAsync<OAuthUserExtension>(token, cancellationToken);
string tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed"; var tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
if (result == null) if (result == null)
{ {
var failedTokenAttemptsCount = await cache.GetAsync<int?>( var failedTokenAttemptsCount = await cache.GetAsync<int?>(
tokenFailedKey, tokenFailedKey,
cancellation) ?? 1; cancellationToken) ?? 1;
var failedTokenCacheExpiration = TimeSpan.FromHours(1); var failedTokenCacheExpiration = TimeSpan.FromHours(1);
@ -362,13 +364,11 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
await cache.SetAsync(tokenFailedKey, await cache.SetAsync(tokenFailedKey,
failedTokenAttemptsCount + 1, failedTokenAttemptsCount + 1,
slidingExpiration: failedTokenCacheExpiration, slidingExpiration: failedTokenCacheExpiration,
cancellationToken: cancellation); cancellationToken: cancellationToken);
return (null, "Invalid or expired token.", false, null); return (null, "Invalid or expired token.", false, null);
} }
await cache.RemoveAsync(tokenFailedKey, cancellation);
const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}."; const string log = "Cache data retrieved for token: {Token}. Fingerprint: {Fingerprint}.";
if (result.User != null) if (result.User != null)
@ -385,6 +385,40 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
else else
logger.LogInformation(log, token, requestInfo.Fingerprint); logger.LogInformation(log, token, requestInfo.Fingerprint);
if ((!string.IsNullOrEmpty(result.Fingerprint) &&
result.Fingerprint != requestInfo.Fingerprint) ||
(!string.IsNullOrEmpty(result.UserAgent) &&
result.UserAgent != requestInfo.UserAgent &&
!string.IsNullOrEmpty(result.Ip)) &&
result.Ip != requestInfo.Ip)
{
logger.LogWarning(
"Potential token compromise detected. " +
"Token {Token} has been used from different location. " +
"Fingerprint: {ExpectedFingerprint} -> {ProvidedFingerprint}, " +
"UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
"Ip: {ExpectedUserIp} -> {ProvidedIp}",
token,
result.Fingerprint,
requestInfo.Fingerprint,
result.UserAgent,
requestInfo.UserAgent,
result.Ip,
requestInfo.Ip);
await cache.RemoveAsync(token, cancellationToken);
return (null, "Invalid or expired token.", false, null);
}
await cache.RemoveAsync(tokenFailedKey, cancellationToken);
result.Ip = requestInfo.Ip;
result.UserAgent = requestInfo.UserAgent;
result.Fingerprint = requestInfo.Fingerprint;
await StoreOAuthUserInCache(token, result, cancellationToken);
return (result.User, result.Message, result.IsSuccess, result.Provider); return (result.User, result.Message, result.IsSuccess, result.Provider);
} }
} }

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0.3</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.3.3</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.3</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Application</AssemblyName> <AssemblyName>Mirea.Api.DataAccess.Application</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup> </PropertyGroup>
@ -16,7 +16,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" /> <PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -8,5 +8,6 @@ public class GetScheduleListQuery : IRequest<ScheduleListVm>
public int[]? DisciplineIds { get; set; } public int[]? DisciplineIds { get; set; }
public int[]? LectureHallIds { get; set; } public int[]? LectureHallIds { get; set; }
public int[]? ProfessorIds { get; set; } public int[]? ProfessorIds { get; set; }
public int[]? LessonTypeIds { get; set; }
public bool? IsEven { get; set; } public bool? IsEven { get; set; }
} }

View File

@ -16,6 +16,9 @@ public class GetScheduleListQueryHandler(ILessonDbContext dbContext) : IRequestH
if (request.IsEven != null) if (request.IsEven != null)
query = query.Where(l => l.IsEven == request.IsEven); query = query.Where(l => l.IsEven == request.IsEven);
if (request.LessonTypeIds != null && request.LessonTypeIds.Length != 0)
query = query.Where(l => l.LessonAssociations!.Any(la => request.LessonTypeIds.Contains(la.TypeOfOccupationId)));
if (request.GroupIds != null && request.GroupIds.Length != 0) if (request.GroupIds != null && request.GroupIds.Length != 0)
query = query.Where(l => request.GroupIds.Contains(l.GroupId)); query = query.Where(l => request.GroupIds.Contains(l.GroupId));

View File

@ -0,0 +1,9 @@
using MediatR;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
public class GetTypeOfOccupationListQuery : IRequest<TypeOfOccupationListVm>
{
public int? Page { get; set; }
public int? PageSize { get; set; }
}

View File

@ -0,0 +1,31 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Mirea.Api.DataAccess.Application.Interfaces.DbContexts.Schedule;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
public class GetTypeOfOccupationListQueryHandler(ITypeOfOccupationDbContext dbContext) : IRequestHandler<GetTypeOfOccupationListQuery, TypeOfOccupationListVm>
{
public async Task<TypeOfOccupationListVm> Handle(GetTypeOfOccupationListQuery request, CancellationToken cancellationToken)
{
var dtos = dbContext.TypeOfOccupations.OrderBy(t => t.Id)
.Select(t => new TypeOfOccupationLookupDto()
{
Id = t.Id,
Name = t.ShortName
});
if (request is { PageSize: not null, Page: not null })
dtos = dtos.Skip(request.Page.Value * request.PageSize.Value).Take(request.PageSize.Value);
var result = await dtos.ToListAsync(cancellationToken);
return new TypeOfOccupationListVm
{
TypeOfOccupations = result
};
}
}

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
/// <summary>
/// Represents a view model containing multiple type of occupations.
/// </summary>
public class TypeOfOccupationListVm
{
/// <summary>
/// The list of type of occupations.
/// </summary>
public IEnumerable<TypeOfOccupationLookupDto> TypeOfOccupations { get; set; } = [];
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
/// <summary>
/// Represents type of occupations.
/// </summary>
public class TypeOfOccupationLookupDto
{
/// <summary>
/// The unique identifier for the occupation.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The name of the occupation.
/// </summary>
public required string Name { get; set; }
}

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0.1</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.3.1</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.1</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Domain</AssemblyName> <AssemblyName>Mirea.Api.DataAccess.Domain</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup> </PropertyGroup>

View File

@ -5,25 +5,26 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0.3</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.3.3</AssemblyVersion> <AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.3</FileVersion> <FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.DataAccess.Persistence</AssemblyName> <AssemblyName>Mirea.Api.DataAccess.Persistence</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.MySql" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.MySql" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Sqlite" Version="8.1.0" /> <PackageReference Include="AspNetCore.HealthChecks.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.11" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.2" />
<PackageReference Include="MySqlConnector" Version="2.4.0" /> <PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.11" />
<PackageReference Include="SQLitePCLRaw.core" Version="2.1.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="winsomnia.net" value="https://git.winsomnia.net/api/packages/Winsomnia/nuget/index.json" />
</packageSources>
<packageSourceCredentials>
<winsomnia.net>
<add key="Username" value="%NUGET_USERNAME%" />
<add key="ClearTextPassword" value="%NUGET_PASSWORD%" />
</winsomnia.net>
</packageSourceCredentials>
</configuration>