Compare commits
50 Commits
07111b9b61
...
master
Author | SHA1 | Date | |
---|---|---|---|
95692a6a1f
|
|||
13eb3c0033
|
|||
f6d1543108 | |||
a4721c9739 | |||
46bbc34956 | |||
047ccfa754 | |||
b0d9a67c1c | |||
3eb043b24c | |||
4cd476764d | |||
90b4662dda | |||
e7edc79ebc | |||
aabeed0aa5 | |||
e79ec360ea | |||
31c1d2804d | |||
ea4c8b61e0 | |||
b40e394bcf | |||
885b937b0b | |||
dc08285ec8 | |||
b3a0964aac | |||
7d6b21c5bb | |||
93912caf01 | |||
c725cfed32 | |||
7c7707b1e2 | |||
1687e9d89b | |||
8d1b709b43 | |||
ce6b0f2673 | |||
16afc0bc69 | |||
c9bc6a3565 | |||
ad8f356fc1 | |||
dda0a29300 | |||
369901db78 | |||
a67b72b7fb | |||
2453b2bd51 | |||
5870eef552 | |||
52de98969d | |||
bc86e077bd | |||
03b6560bc4 | |||
5bcb7bfbc1 | |||
38fba5556f | |||
fd26178a24 | |||
7eb307b65e | |||
56c7196100 | |||
92081156cf | |||
6358410f18 | |||
e79ddf220f | |||
c3c9844e2f | |||
206720cd63 | |||
d9f4176aca | |||
1de344ac25 | |||
61a11ea223 |
@ -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
|
||||||
|
@ -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
31
.github/workflows/code-analyze.yaml
vendored
Normal 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 }}
|
@ -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
|
24
ApiDto/Common/CronUpdateSkip.cs
Normal file
24
ApiDto/Common/CronUpdateSkip.cs
Normal 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; }
|
||||||
|
}
|
@ -19,4 +19,4 @@ public enum OAuthProvider
|
|||||||
/// OAuth provider for Mail.ru.
|
/// OAuth provider for Mail.ru.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
MailRu
|
MailRu
|
||||||
}
|
}
|
@ -29,4 +29,4 @@ public class PasswordPolicy
|
|||||||
/// Gets or sets a value indicating whether at least one special character is required in the password.
|
/// Gets or sets a value indicating whether at least one special character is required in the password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireSpecialCharacter { get; set; }
|
public bool RequireSpecialCharacter { get; set; }
|
||||||
}
|
}
|
@ -14,4 +14,4 @@ public enum TwoFactorAuthentication
|
|||||||
/// TOTP (Time-based One-Time Password) is required for additional verification.
|
/// TOTP (Time-based One-Time Password) is required for additional verification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
TotpRequired,
|
TotpRequired,
|
||||||
}
|
}
|
@ -27,4 +27,4 @@ public class CreateUserRequest
|
|||||||
[Required]
|
[Required]
|
||||||
[MinLength(2)]
|
[MinLength(2)]
|
||||||
public required string Password { get; set; }
|
public required string Password { get; set; }
|
||||||
}
|
}
|
@ -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;
|
||||||
|
}
|
@ -28,4 +28,4 @@ public class CampusDetailsResponse
|
|||||||
/// Gets or sets the address of the campus (optional).
|
/// Gets or sets the address of the campus (optional).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
}
|
}
|
23
ApiDto/Responses/Configuration/CronUpdateScheduleResponse.cs
Normal file
23
ApiDto/Responses/Configuration/CronUpdateScheduleResponse.cs
Normal 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; }
|
||||||
|
}
|
@ -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
|
||||||
{
|
{
|
||||||
|
21
ApiDto/Responses/LessonTypeResponse.cs
Normal file
21
ApiDto/Responses/LessonTypeResponse.cs
Normal 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; }
|
||||||
|
}
|
@ -114,4 +114,4 @@ public class ScheduleResponse
|
|||||||
/// Gets or sets the links to online meetings for the schedule entry.
|
/// Gets or sets the links to online meetings for the schedule entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required IEnumerable<string?> LinkToMeet { get; set; }
|
public required IEnumerable<string?> LinkToMeet { get; set; }
|
||||||
}
|
}
|
@ -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}"
|
||||||
|
@ -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
|
||||||
|
19
Endpoint/Common/MapperDto/CronUpdateSkipConverter.cs
Normal file
19
Endpoint/Common/MapperDto/CronUpdateSkipConverter.cs
Normal 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();
|
||||||
|
}
|
@ -11,4 +11,4 @@ public static class PairPeriodTimeConverter
|
|||||||
|
|
||||||
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) =>
|
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) =>
|
||||||
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
|
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
|
||||||
}
|
}
|
78
Endpoint/Common/Services/CronUpdateSkipService.cs
Normal file
78
Endpoint/Common/Services/CronUpdateSkipService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
}
|
}
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -64,4 +64,4 @@ public static class JwtConfiguration
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -95,8 +95,8 @@ 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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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
|
||||||
|
@ -2,4 +2,4 @@
|
|||||||
public interface ISaveSettings
|
public interface ISaveSettings
|
||||||
{
|
{
|
||||||
void SaveSetting();
|
void SaveSetting();
|
||||||
}
|
}
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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.",
|
||||||
|
@ -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)
|
||||||
{
|
{
|
28
Endpoint/Configuration/SwaggerOptions/EnumSchemaFilter.cs
Normal file
28
Endpoint/Configuration/SwaggerOptions/EnumSchemaFilter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
{
|
{
|
40
Endpoint/Configuration/SwaggerOptions/TagSchemeFilter.cs
Normal file
40
Endpoint/Configuration/SwaggerOptions/TagSchemeFilter.cs
Normal 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] });
|
||||||
|
}
|
||||||
|
}
|
11
Endpoint/Controllers/ConfigurationBaseController.cs
Normal file
11
Endpoint/Controllers/ConfigurationBaseController.cs
Normal 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;
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
@ -316,4 +315,4 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
|||||||
|
|
||||||
return Ok(password);
|
return Ok(password);
|
||||||
}
|
}
|
||||||
}
|
}
|
219
Endpoint/Controllers/V1/Configuration/ScheduleController.cs
Normal file
219
Endpoint/Controllers/V1/Configuration/ScheduleController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
43
Endpoint/Controllers/V1/LessonTypeController.cs
Normal file
43
Endpoint/Controllers/V1/LessonTypeController.cs
Normal 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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
@ -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>
|
||||||
|
@ -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();
|
||||||
}
|
}
|
@ -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 non—standard 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:**
|
||||||
|
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
@ -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; }
|
||||||
|
@ -13,4 +13,4 @@ public interface ICacheService
|
|||||||
|
|
||||||
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
|
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
|
||||||
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
|
Task RemoveAsync(string key, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
@ -26,4 +26,4 @@ public class CookieOptions
|
|||||||
|
|
||||||
internal void DropCookie(HttpContext context, string name) =>
|
internal void DropCookie(HttpContext context, string name) =>
|
||||||
SetCookie(context, name, "", DateTimeOffset.MinValue);
|
SetCookie(context, name, "", DateTimeOffset.MinValue);
|
||||||
}
|
}
|
@ -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>
|
||||||
|
@ -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,30 +222,22 @@ 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);
|
authToken.UserAgent,
|
||||||
else
|
requestContext.UserAgent,
|
||||||
logger.LogWarning(
|
authToken.Ip,
|
||||||
error +
|
requestContext.Ip);
|
||||||
"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.Ip,
|
|
||||||
requestContext.UserAgent,
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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>
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
@ -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));
|
||||||
|
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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; } = [];
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -386,4 +386,4 @@ namespace MysqlMigrations.Migrations
|
|||||||
name: "Campus");
|
name: "Campus");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -80,4 +80,4 @@ namespace MysqlMigrations.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -362,4 +362,4 @@ namespace PsqlMigrations.Migrations
|
|||||||
name: "Campus");
|
name: "Campus");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -46,4 +46,4 @@ namespace PsqlMigrations.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -361,4 +361,4 @@ namespace SqliteMigrations.Migrations
|
|||||||
name: "Campus");
|
name: "Campus");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -46,4 +46,4 @@ namespace SqliteMigrations.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.SetNull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -19,4 +19,4 @@ public static class ModelBuilderExtensions
|
|||||||
var applyConcreteMethod = applyGenericMethod.MakeGenericMethod(entityType);
|
var applyConcreteMethod = applyGenericMethod.MakeGenericMethod(entityType);
|
||||||
applyConcreteMethod.Invoke(modelBuilder, [configuration]);
|
applyConcreteMethod.Invoke(modelBuilder, [configuration]);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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>
|
||||||
|
13
nuget.config
13
nuget.config
@ -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>
|
|
Reference in New Issue
Block a user