diff --git a/Wollmilchsau/Components/Pages/Home.razor b/Wollmilchsau/Components/Pages/Home.razor index 4ca34f6..e954f46 100644 --- a/Wollmilchsau/Components/Pages/Home.razor +++ b/Wollmilchsau/Components/Pages/Home.razor @@ -54,6 +54,7 @@ else { base.OnInitialized(); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); } private Task DownloadCsvAsync() diff --git a/Wollmilchsau/Models/Classroom.cs b/Wollmilchsau/Models/Classroom.cs new file mode 100644 index 0000000..37586b1 --- /dev/null +++ b/Wollmilchsau/Models/Classroom.cs @@ -0,0 +1,3 @@ +namespace Wollmilchsau.Models; + +public sealed record Classroom(string Name); diff --git a/Wollmilchsau/Services/Ldap/ILdapService.cs b/Wollmilchsau/Services/Ldap/ILdapService.cs index 473b176..7c01c4e 100644 --- a/Wollmilchsau/Services/Ldap/ILdapService.cs +++ b/Wollmilchsau/Services/Ldap/ILdapService.cs @@ -4,5 +4,8 @@ namespace Wollmilchsau.Services.Ldap; public interface ILdapService { + Task GetUserCount(CancellationToken cancellationToken = default); IAsyncEnumerable GetAllUsers(CancellationToken cancellationToken = default); + Task GetClassroomCount(CancellationToken cancellationToken = default); + IAsyncEnumerable GetAllClassrooms(CancellationToken cancellationToken = default); } diff --git a/Wollmilchsau/Services/Ldap/LdapOptions.cs b/Wollmilchsau/Services/Ldap/LdapOptions.cs index 31de766..49232d8 100644 --- a/Wollmilchsau/Services/Ldap/LdapOptions.cs +++ b/Wollmilchsau/Services/Ldap/LdapOptions.cs @@ -27,4 +27,10 @@ public sealed class LdapOptions [Required] public string TeacherRole { get; init; } = string.Empty; + + [Required] + public string GroupBase { get; init; } = string.Empty; + + [Required] + public string KlasseGroupFilter { get; init; } = string.Empty; } diff --git a/Wollmilchsau/Services/Ldap/LdapService.cs b/Wollmilchsau/Services/Ldap/LdapService.cs index 5ac56cf..bd55214 100644 --- a/Wollmilchsau/Services/Ldap/LdapService.cs +++ b/Wollmilchsau/Services/Ldap/LdapService.cs @@ -10,6 +10,21 @@ public sealed class LdapService(IOptions options) : ILdapService { private readonly LdapOptions _options = options.Value; + public Task GetUserCount(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var connection = CreateConnection(); + var request = new SearchRequest( + BuildDistinguishedName(_options.UserBase), + GetSupportedRolesFilter(), + SearchScope.Subtree, + "1.1"); + var response = (SearchResponse)connection.SendRequest(request); + + return Task.FromResult(response.Entries.Count); + } + public async IAsyncEnumerable GetAllUsers( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -24,6 +39,45 @@ public sealed class LdapService(IOptions options) : ILdapService } } + public Task GetClassroomCount(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var connection = CreateConnection(); + var request = new SearchRequest( + BuildDistinguishedName(_options.GroupBase), + _options.KlasseGroupFilter, + SearchScope.Subtree, + "1.1"); + var response = (SearchResponse)connection.SendRequest(request); + + return Task.FromResult(response.Entries.Count); + } + + public async IAsyncEnumerable GetAllClassrooms( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var connection = CreateConnection(); + var request = new SearchRequest( + BuildDistinguishedName(_options.GroupBase), + _options.KlasseGroupFilter, + SearchScope.Subtree, + "cn"); + var response = (SearchResponse)connection.SendRequest(request); + + foreach (var entry in response.Entries.Cast()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var commonName = GetFirstAttributeValue(entry, "cn") + ?? throw new InvalidOperationException($"LDAP classroom entry '{entry.DistinguishedName}' is missing cn."); + yield return new Classroom(GetClassroomName(commonName)); + await Task.Yield(); + } + } + private LdapConnection CreateConnection() { var ldapUri = new Uri(_options.LdapUri, UriKind.Absolute); @@ -52,7 +106,7 @@ public sealed class LdapService(IOptions options) : ILdapService var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase); var request = new SearchRequest( BuildDistinguishedName(_options.UserBase), - $"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))", + GetSupportedRolesFilter(studentRoleDn, teacherRoleDn), SearchScope.Subtree, "uid", "sn", @@ -102,6 +156,30 @@ public sealed class LdapService(IOptions options) : ILdapService throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is not a member of a supported role."); } + private string GetSupportedRolesFilter() + { + var studentRoleDn = BuildDistinguishedName(_options.StudentsRole, _options.RoleBase); + var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase); + + return GetSupportedRolesFilter(studentRoleDn, teacherRoleDn); + } + + private static string GetSupportedRolesFilter(string studentRoleDn, string teacherRoleDn) + { + return $"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))"; + } + + private static string GetClassroomName(string commonName) + { + const string classroomPrefix = "klasse."; + if (!commonName.StartsWith(classroomPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"LDAP classroom cn '{commonName}' does not start with '{classroomPrefix}'."); + } + + return commonName[classroomPrefix.Length..]; + } + private string BuildDistinguishedName(params string[] relativeNames) { return string.Join(',', relativeNames.Append(_options.BaseDN)); diff --git a/Wollmilchsau/Store/AdminStore/AdminActions.cs b/Wollmilchsau/Store/AdminStore/AdminActions.cs index c7629b7..f2a709b 100644 --- a/Wollmilchsau/Store/AdminStore/AdminActions.cs +++ b/Wollmilchsau/Store/AdminStore/AdminActions.cs @@ -6,4 +6,6 @@ public sealed record GetAllMoodleUsers(); public sealed record GetAllElementUsers(); public sealed record GetAllLdapUsers(); public sealed record SetLdapUsers(IReadOnlyCollection Users); +public sealed record GetAllClassrooms(); +public sealed record SetClassrooms(IReadOnlyCollection Classrooms); public sealed record GetAllMsolUsers(); diff --git a/Wollmilchsau/Store/AdminStore/AdminEffects.cs b/Wollmilchsau/Store/AdminStore/AdminEffects.cs index de96fcd..2036cf1 100644 --- a/Wollmilchsau/Store/AdminStore/AdminEffects.cs +++ b/Wollmilchsau/Store/AdminStore/AdminEffects.cs @@ -9,36 +9,99 @@ namespace Wollmilchsau.Store.AdminStore; public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cache) { private const string LdapUsersCacheKey = "admin:ldap-users"; + private const string ClassroomsCacheKey = "admin:classrooms"; [EffectMethod] public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) { + var ldapUserCount = await ldapService.GetUserCount(); var cachedUsers = await cache.GetStringAsync(LdapUsersCacheKey); IReadOnlyCollection users; if (cachedUsers is not null) { - users = JsonSerializer.Deserialize(cachedUsers) - ?? throw new InvalidOperationException("The cached LDAP users could not be deserialized."); + var deserializedUsers = JsonSerializer.Deserialize(cachedUsers); + if (deserializedUsers is not null && deserializedUsers.Length == ldapUserCount) + { + users = deserializedUsers; + } + else + { + await cache.RemoveAsync(LdapUsersCacheKey); + users = await LoadAndCacheLdapUsers(ldapUserCount); + } } else { - var loadedUsers = new List(); - await foreach (var user in ldapService.GetAllUsers()) - { - loadedUsers.Add(user); - } - - users = loadedUsers; - await cache.SetStringAsync( - LdapUsersCacheKey, - JsonSerializer.Serialize(users), - new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(10) - }); + users = await LoadAndCacheLdapUsers(ldapUserCount); } dispatcher.Dispatch(new SetLdapUsers(users)); } + + private async Task> LoadAndCacheLdapUsers(int ldapUserCount) + { + var loadedUsers = new List(ldapUserCount); + await foreach (var user in ldapService.GetAllUsers()) + { + loadedUsers.Add(user); + } + + await cache.SetStringAsync( + LdapUsersCacheKey, + JsonSerializer.Serialize(loadedUsers), + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + + return loadedUsers; + } + + [EffectMethod] + public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher) + { + var ldapClassroomCount = await ldapService.GetClassroomCount(); + var cachedClassrooms = await cache.GetStringAsync(ClassroomsCacheKey); + IReadOnlyCollection classrooms; + + if (cachedClassrooms is not null) + { + var deserializedClassrooms = JsonSerializer.Deserialize(cachedClassrooms); + if (deserializedClassrooms is not null && deserializedClassrooms.Length == ldapClassroomCount) + { + classrooms = deserializedClassrooms; + } + else + { + await cache.RemoveAsync(ClassroomsCacheKey); + classrooms = await LoadAndCacheClassrooms(ldapClassroomCount); + } + } + else + { + classrooms = await LoadAndCacheClassrooms(ldapClassroomCount); + } + + dispatcher.Dispatch(new SetClassrooms(classrooms)); + } + + private async Task> LoadAndCacheClassrooms(int ldapClassroomCount) + { + var loadedClassrooms = new List(ldapClassroomCount); + await foreach (var classroom in ldapService.GetAllClassrooms()) + { + loadedClassrooms.Add(classroom); + } + + await cache.SetStringAsync( + ClassroomsCacheKey, + JsonSerializer.Serialize(loadedClassrooms), + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + + return loadedClassrooms; + } } diff --git a/Wollmilchsau/Store/AdminStore/AdminReducers.cs b/Wollmilchsau/Store/AdminStore/AdminReducers.cs index ea4641c..19b00c4 100644 --- a/Wollmilchsau/Store/AdminStore/AdminReducers.cs +++ b/Wollmilchsau/Store/AdminStore/AdminReducers.cs @@ -14,4 +14,15 @@ public static class AdminReducers LdapUsersLoaded = true }; } + + [ReducerMethod] + public static AdminState ReduceSetClassrooms(AdminState state, SetClassrooms action) + { + return state with + { + Classrooms = action.Classrooms + .OrderBy(classroom => classroom.Name, StringComparer.OrdinalIgnoreCase) + .ToImmutableList() + }; + } } diff --git a/Wollmilchsau/Store/AdminStore/AdminState.cs b/Wollmilchsau/Store/AdminStore/AdminState.cs index f34a6b3..e613447 100644 --- a/Wollmilchsau/Store/AdminStore/AdminState.cs +++ b/Wollmilchsau/Store/AdminStore/AdminState.cs @@ -11,6 +11,7 @@ public sealed record AdminState public ImmutableDictionary ElementUsers { get; init; } = []; public ImmutableDictionary MoodleUsers { get; init; } = []; public ImmutableDictionary LdapUsers { get; init; } = []; + public ImmutableList Classrooms { get; init; } = []; public bool LdapUsersLoaded { get; init; } public ImmutableDictionary MsolUsers { get; init; } = []; }