diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index 05f7254..0c0ac32 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -47,6 +47,9 @@ public class EndpointAuthorizationTests : IClassFixture 3, [new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)])); stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any()).Returns(pagedLectures); + stub.GetMyEnrollmentsIcsAsync(Arg.Any()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"); + stub.GetEnrollmentIcsAsync(Arg.Any(), Arg.Any()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"); + stub.GetCalendarSubscriptionTokenAsync(Arg.Any()).Returns("test-token"); + stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token") + .Returns(Task.FromException(new ForbiddenException("Invalid calendar subscription token."))); + stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is(token => token != "bad-token")) + .Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")); stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs index 0559834..9352c13 100644 --- a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; @@ -193,6 +194,56 @@ public class UserServiceTests Assert.Equal(2, Assert.Single(result.Items).Id); } + [Fact] + public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs() + { + await using var db = CreateDbContext(); + var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc); + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(1, startsAt)); + db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var token = await service.GetCalendarSubscriptionTokenAsync(1); + var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token); + + Assert.Contains("BEGIN:VCALENDAR", ics); + Assert.Contains("Lecture 1", ics); + } + + [Fact] + public async Task CalendarSubscriptionToken_RejectsTamperedToken() + { + await using var db = CreateDbContext(); + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + await db.SaveChangesAsync(); + var service = CreateService(db); + var token = await service.GetCalendarSubscriptionTokenAsync(1); + var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A'); + + await Assert.ThrowsAsync(() => + service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered)); + } + + [Fact] + public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment() + { + await using var db = CreateDbContext(); + var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc); + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(1853, startsAt)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var ics = await service.GetEnrollmentIcsAsync(1, 1853); + + Assert.Contains("BEGIN:VCALENDAR", ics); + Assert.Contains("Lecture 1853", ics); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -215,7 +266,13 @@ public class UserServiceTests .Returns(Task.CompletedTask); var gamification = new GamificationService(db, notifications, NullLogger.Instance); - return new UserService(db, gamification); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Secret"] = "test-calendar-subscription-secret-32chars" + }) + .Build(); + return new UserService(db, gamification, config); } private static void SeedLevelThresholds(AppDbContext db) diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index 3ad558e..5bef4bf 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Domain.Enums; using System.Security.Claims; +using System.Text; namespace UniVerse.Api.Controllers; @@ -83,6 +84,52 @@ public class UsersController : ControllerBase public async Task MyEnrollments([FromQuery] PaginationRequest pagination) => Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination)); + [HttpGet("me/enrollments/calendar-subscription")] + [ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> CalendarSubscription() + { + var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId); + var feedUrl = Url.Action( + nameof(CalendarEnrollmentsIcs), + null, + new { token }, + Request.Scheme) + ?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics"; + + return Ok(new CalendarSubscriptionDto(feedUrl)); + } + + [AllowAnonymous] + [HttpGet("calendar/enrollments/{token}.ics")] + [Produces("text/calendar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task CalendarEnrollmentsIcs(string token) + { + var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token); + return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics"); + } + + [HttpGet("me/enrollments.ics")] + [Produces("text/calendar")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task MyEnrollmentsIcs() + { + var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId); + return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics"); + } + + [HttpGet("me/enrollments/{lectureId:int}.ics")] + [Produces("text/calendar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task EnrollmentIcs(int lectureId) + { + var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId); + return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics"); + } + /// Получить отзывы текущего пользователя. /// Параметры пагинации. /// Список отзывов (пагинированный). diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index 5bb5999..bd2ff7a 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -3789,6 +3789,146 @@ ] } }, + "/api/v1/users/me/enrollments/calendar-subscription": { + "get": { + "tags": [ + "Users" + ], + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarSubscriptionDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/calendar/enrollments/{token}.ics": { + "get": { + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "text/calendar": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/users/me/enrollments.ics": { + "get": { + "tags": [ + "Users" + ], + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized — JWT token missing or invalid" + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/enrollments/{lectureId}.ics": { + "get": { + "tags": [ + "Users" + ], + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "lectureId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "text/calendar": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized — JWT token missing or invalid" + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, "/api/v1/users/me/reviews": { "get": { "tags": [ @@ -4843,6 +4983,16 @@ }, "additionalProperties": false }, + "CalendarSubscriptionDto": { + "type": "object", + "properties": { + "feedUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "CoinTransactionDto": { "type": "object", "properties": { diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index b997704..013da43 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -51,6 +51,8 @@ public record AdminDashboardStatsDto( public record EnrollmentSlotRuleDto(int Level, int Slots); +public record CalendarSubscriptionDto(string FeedUrl); + public record UpdateUserRequest( string? DisplayName, string? AvatarUrl diff --git a/backend/UniVerse.Application/Interfaces/IUserService.cs b/backend/UniVerse.Application/Interfaces/IUserService.cs index be0b46d..9519a5a 100644 --- a/backend/UniVerse.Application/Interfaces/IUserService.cs +++ b/backend/UniVerse.Application/Interfaces/IUserService.cs @@ -12,6 +12,10 @@ public interface IUserService Task GetStatsAsync(int id); Task GetAdminDashboardStatsAsync(); Task> GetEnrollmentsAsync(int id, PaginationRequest pagination); + Task GetMyEnrollmentsIcsAsync(int userId); + Task GetEnrollmentIcsAsync(int userId, int lectureId); + Task GetCalendarSubscriptionTokenAsync(int userId); + Task GetEnrollmentsIcsBySubscriptionTokenAsync(string token); Task> GetAllAsync(UserFilterRequest filter); Task SetRolesAsync(int id, IReadOnlyCollection roles); Task SetActiveAsync(int id, bool isActive); diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 2c29b8d..d44e503 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -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 GetByIdAsync(int id) @@ -115,6 +130,154 @@ public class UserService : IUserService pagination.PageSize); } + + public async Task 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 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 GetCalendarSubscriptionTokenAsync(int userId) + { + if (!await _db.Users.AnyAsync(u => u.Id == userId)) + throw new NotFoundException("User", userId); + + Span 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 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 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 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 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> GetAllAsync(UserFilterRequest filter) { var query = _db.Users.AsQueryable(); diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index 193ece1..ffcdb1c 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -13,6 +13,7 @@ + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index db72f1a..7ff5dab 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,3 +81,30 @@ export function extractItems(payload: T[] | { items?: T[] } | undefined): T[] if (Array.isArray(payload)) return payload return payload?.items ?? [] } + + +export async function apiRequestBlob( + path: string, + options: RequestInit & { query?: Record } = {}, +): Promise { + const headers = new Headers(options.headers) + if (!headers.has('Accept')) headers.set('Accept', 'text/calendar') + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`) + + const response = await fetch(makeUrl(path, options.query), { + ...options, + headers, + credentials: 'include', + }) + + if (!response.ok) { + const body = await parseResponse(response) + const message = + typeof body === 'object' && body && 'message' in body + ? String((body as { message: unknown }).message) + : `API request failed with status ${response.status}` + throw new ApiError(message, response.status, body) + } + + return response.blob() +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6af0b79..b61f772 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,4 @@ -import { apiRequest, extractItems } from './client' +import { apiRequest, apiRequestBlob, extractItems } from './client' import type { AchievementDto, AuthResponse, @@ -19,6 +19,7 @@ import type { UpdateReviewPromptRequest, UserAchievementDto, AdminDashboardStatsDto, + CalendarSubscriptionDto, CurrentUserDto, UserDto, UserQuery, @@ -76,6 +77,11 @@ export const usersApi = { ) return extractItems(payload) }, + downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'), + downloadEnrollmentIcs: (lectureId: string | number) => + apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`), + getCalendarSubscription: () => + apiRequest('/users/me/enrollments/calendar-subscription'), async myAchievements() { const payload = await apiRequest< PagedResult | UserAchievementDto[] | AchievementDto[] diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4fa7e2d..add7dbc 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -83,6 +83,10 @@ export interface AdminDashboardStatsDto { pendingReviewsCount: number } +export interface CalendarSubscriptionDto { + feedUrl: string +} + export interface EnrollmentSlotRuleDto { level: number slots: number diff --git a/frontend/src/utils/downloadFile.ts b/frontend/src/utils/downloadFile.ts new file mode 100644 index 0000000..b4c04e4 --- /dev/null +++ b/frontend/src/utils/downloadFile.ts @@ -0,0 +1,10 @@ +export function downloadFile(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue index cc6225c..b056a0a 100644 --- a/frontend/src/views/student/DashboardView.vue +++ b/frontend/src/views/student/DashboardView.vue @@ -2,6 +2,8 @@ import { computed, inject, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { usersApi } from '@/api' +import { downloadFile } from '@/utils/downloadFile' import { useLecturesStore } from '@/stores/lectures' import { useUserStore } from '@/stores/user' import GlassCard from '@/components/ui/GlassCard.vue' @@ -66,6 +68,16 @@ const levelProgressText = computed(() => : `${userXp.value} XP`, ) +async function downloadLectureIcs(id: string) { + try { + const blob = await usersApi.downloadEnrollmentIcs(id) + downloadFile(blob, `lecture-${id}.ics`) + addToast?.("Файл календаря скачан", "success") + } catch (err) { + addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error") + } +} + onMounted(async () => { await Promise.all([ lectures.all.length ? Promise.resolve() : lectures.fetchLectures(), @@ -125,7 +137,7 @@ async function registerLecture(id: string) { - + diff --git a/frontend/src/views/student/LectureDetailView.vue b/frontend/src/views/student/LectureDetailView.vue index 807d871..7316dbd 100644 --- a/frontend/src/views/student/LectureDetailView.vue +++ b/frontend/src/views/student/LectureDetailView.vue @@ -1,6 +1,8 @@