feat: добавил поддержку подписки на календарь и экспорт расписания лекций в формате .ics
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Ical.Net;
|
||||
using Ical.Net.CalendarComponents;
|
||||
using Ical.Net.DataTypes;
|
||||
using Ical.Net.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Lectures;
|
||||
using UniVerse.Application.DTOs.Users;
|
||||
@@ -13,13 +21,20 @@ namespace UniVerse.Infrastructure.Services;
|
||||
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private const byte CalendarTokenVersion = 1;
|
||||
private const int CalendarTokenPayloadLength = 5;
|
||||
private const int CalendarTokenSignatureLength = 32;
|
||||
private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1";
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IGamificationService _gamification;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public UserService(AppDbContext db, IGamificationService gamification)
|
||||
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_gamification = gamification;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<UserDto> GetByIdAsync(int id)
|
||||
@@ -115,6 +130,154 @@ public class UserService : IUserService
|
||||
pagination.PageSize);
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> GetMyEnrollmentsIcsAsync(int userId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
var lectures = await _db.LectureEnrollments
|
||||
.Where(e => e.UserId == userId)
|
||||
.Include(e => e.Lecture)
|
||||
.ThenInclude(l => l.Teacher)
|
||||
.Include(e => e.Lecture)
|
||||
.ThenInclude(l => l.Location)
|
||||
.OrderBy(e => e.Lecture.StartsAt)
|
||||
.Select(e => e.Lecture)
|
||||
.ToListAsync();
|
||||
|
||||
return BuildIcs(lectures, userId);
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrollmentIcsAsync(int userId, int lectureId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
var lecture = await _db.Lectures
|
||||
.Include(l => l.Teacher)
|
||||
.Include(l => l.Location)
|
||||
.FirstOrDefaultAsync(l => l.Id == lectureId)
|
||||
?? throw new NotFoundException("Lecture", lectureId);
|
||||
|
||||
return BuildIcs([lecture], userId);
|
||||
}
|
||||
|
||||
public async Task<string> GetCalendarSubscriptionTokenAsync(int userId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
Span<byte> payload = stackalloc byte[CalendarTokenPayloadLength];
|
||||
payload[0] = CalendarTokenVersion;
|
||||
BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId);
|
||||
|
||||
var signature = SignCalendarTokenPayload(payload);
|
||||
var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength];
|
||||
payload.CopyTo(tokenBytes);
|
||||
signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength));
|
||||
|
||||
return ToBase64Url(tokenBytes);
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token)
|
||||
{
|
||||
var userId = ValidateCalendarSubscriptionToken(token);
|
||||
return await GetMyEnrollmentsIcsAsync(userId);
|
||||
}
|
||||
|
||||
private int ValidateCalendarSubscriptionToken(string token)
|
||||
{
|
||||
var tokenBytes = FromBase64Url(token);
|
||||
if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength);
|
||||
var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength);
|
||||
if (payload[0] != CalendarTokenVersion)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var expectedSignature = SignCalendarTokenPayload(payload);
|
||||
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]);
|
||||
if (userId <= 0)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
private byte[] SignCalendarTokenPayload(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var calendarKey = DeriveCalendarTokenKey();
|
||||
return HMACSHA256.HashData(calendarKey, payload);
|
||||
}
|
||||
|
||||
private byte[] DeriveCalendarTokenKey()
|
||||
{
|
||||
var jwtSecret = _config["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret))
|
||||
throw new InvalidOperationException("Jwt:Secret is not configured.");
|
||||
|
||||
return HMACSHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(jwtSecret),
|
||||
Encoding.UTF8.GetBytes(CalendarTokenKeyContext));
|
||||
}
|
||||
|
||||
private static string ToBase64Url(ReadOnlySpan<byte> bytes) =>
|
||||
Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildIcs(List<Domain.Entities.Lecture> lectures, int userId)
|
||||
{
|
||||
var calendar = new Calendar
|
||||
{
|
||||
Method = "PUBLISH",
|
||||
ProductId = "-//UniVerse//Lectures Calendar//EN"
|
||||
};
|
||||
|
||||
foreach (var lecture in lectures)
|
||||
{
|
||||
var location = lecture.Location is null
|
||||
? string.Empty
|
||||
: $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}";
|
||||
|
||||
var teacherName = lecture.Teacher?.DisplayName
|
||||
?? lecture.Teacher?.Email
|
||||
?? "не указан";
|
||||
|
||||
calendar.Events.Add(new CalendarEvent
|
||||
{
|
||||
Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local",
|
||||
Summary = lecture.Title,
|
||||
Description = $"{lecture.Description}\nПреподаватель: {teacherName}",
|
||||
Location = location,
|
||||
DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)),
|
||||
DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)),
|
||||
DtStamp = new CalDateTime(DateTime.UtcNow)
|
||||
});
|
||||
}
|
||||
|
||||
return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
|
||||
{
|
||||
var query = _db.Users.AsQueryable();
|
||||
|
||||
Reference in New Issue
Block a user