Untis API Integration
This commit is contained in:
@@ -244,45 +244,79 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── WebUntis (granulare, zustandslose Abrufe) ─────────────────────────────
|
||||
// ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ──────
|
||||
|
||||
public static void MapWebUntisEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/webuntis").RequireAuthorization();
|
||||
|
||||
group.MapGet("/schoolyears", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetSchoolYearsAsync(cancellationToken)));
|
||||
group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid));
|
||||
});
|
||||
|
||||
group.MapGet("/classes", ([FromQuery] int schoolyearId, WebUntisClient client,
|
||||
group.MapPost("/connection", async (WebUntisConnectRequest request, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(request.School) || string.IsNullOrWhiteSpace(request.Username) ||
|
||||
string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Schule, Benutzername und Passwort sind erforderlich.");
|
||||
return await WebUntisResult(() => connections.ConnectAsync(uid, request, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapDelete("/connection", async (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
await connections.DisconnectAsync(uid);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapGet("/schoolyears", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetSchoolYearsAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/classes", ([FromQuery] int schoolyearId, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback,
|
||||
CancellationToken cancellationToken) => schoolyearId <= 0
|
||||
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
|
||||
: WebUntisResult(() => client.GetClassesAsync(schoolyearId, cancellationToken)));
|
||||
: WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassesAsync(schoolyearId, cancellationToken)));
|
||||
|
||||
group.MapGet("/teachers", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetTeachersAsync(cancellationToken)));
|
||||
group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/student-report", ([FromQuery] string? className, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetStudentReportAsync(className, cancellationToken)));
|
||||
group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetStudentReportAsync(className, cancellationToken)));
|
||||
|
||||
group.MapGet("/holidays", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetHolidaysAsync(cancellationToken)));
|
||||
group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/timegrid", (WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetTimeGridAsync(cancellationToken)));
|
||||
group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
|
||||
[FromQuery] int? departmentId, WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
[FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ValidDateRange(startDate, endDate, 31, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
|
||||
return WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) ||
|
||||
!Enum.IsDefined(parsedType))
|
||||
@@ -292,40 +326,51 @@ public static class Endpoints
|
||||
return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 62, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
|
||||
return WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
|
||||
[FromQuery] int endDate, WebUntisClient client, CancellationToken cancellationToken) =>
|
||||
[FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections,
|
||||
WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (studentKey <= 0)
|
||||
return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 400, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
|
||||
return WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (studentId <= 0)
|
||||
return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein."));
|
||||
if (!ValidDateRange(startDate, endDate, 400, out var error))
|
||||
return Task.FromResult<IResult>(Results.BadRequest(error));
|
||||
return WebUntisResult(() =>
|
||||
client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
|
||||
return WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
|
||||
});
|
||||
|
||||
group.MapGet("/class-register/categories", (WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetClassRegisterCategoriesAsync(cancellationToken)));
|
||||
group.MapGet("/class-register/categories", (ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterCategoriesAsync(cancellationToken)));
|
||||
|
||||
group.MapGet("/class-register/category-groups", (WebUntisClient client,
|
||||
CancellationToken cancellationToken) =>
|
||||
WebUntisResult(() => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
|
||||
group.MapGet("/class-register/category-groups", (ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
|
||||
WithWebUntisClient(user, connections, fallback,
|
||||
client => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
|
||||
}
|
||||
|
||||
private static Task<IResult> WithWebUntisClient<T>(ClaimsPrincipal user,
|
||||
WebUntisConnectionStore connections, WebUntisClient fallback, Func<WebUntisClient, Task<T>> operation)
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Task.FromResult<IResult>(Results.Unauthorized());
|
||||
return WebUntisResult(() => operation(connections.GetClient(uid) ?? fallback));
|
||||
}
|
||||
|
||||
private static async Task<IResult> WebUntisResult<T>(Func<Task<T>> operation)
|
||||
|
||||
@@ -119,6 +119,7 @@ builder.Services.AddHttpClient("webuntis", client =>
|
||||
builder.Services.AddSingleton(sp => new WebUntisClient(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>()));
|
||||
builder.Services.AddSingleton<WebUntisConnectionStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
@@ -287,7 +287,10 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
client = configuration.Client,
|
||||
}, null, cancellationToken);
|
||||
_sessionId = OptionalString(result, "sessionId")
|
||||
?? throw new WebUntisException("WebUntis-Login fehlgeschlagen: Keine sessionId erhalten.");
|
||||
?? throw new WebUntisException(
|
||||
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
|
||||
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
|
||||
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
|
||||
}
|
||||
|
||||
_activeRequests++;
|
||||
@@ -499,24 +502,71 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
{
|
||||
var schoolValue = _options.School.Trim();
|
||||
var username = _options.Username.Trim();
|
||||
var password = _options.Password.Trim();
|
||||
// Passwörter dürfen führende/abschließende Leerzeichen enthalten und werden deshalb
|
||||
// anders als Schule/Benutzername nicht normalisiert.
|
||||
var password = _options.Password;
|
||||
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
|
||||
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
|
||||
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
|
||||
|
||||
var cleaned = schoolValue.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).Split('/')[0];
|
||||
var school = cleaned.Contains('.') ? cleaned.Split('.')[0] : cleaned;
|
||||
var host = string.IsNullOrWhiteSpace(_options.Host)
|
||||
? (cleaned.Contains('.') ? cleaned : $"{school}.webuntis.com")
|
||||
: _options.Host.Trim().Replace("https://", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/');
|
||||
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns))
|
||||
var (school, host) = ResolveLocation(schoolValue, _options.Host);
|
||||
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns) ||
|
||||
!(host.Equals("webuntis.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith(".webuntis.com", StringComparison.OrdinalIgnoreCase)))
|
||||
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
|
||||
return new Config(school, host, username, password,
|
||||
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
|
||||
}
|
||||
|
||||
private static (string School, string Host) ResolveLocation(string schoolValue, string? hostValue)
|
||||
{
|
||||
var school = schoolValue.Trim();
|
||||
string? hostFromSchool = null;
|
||||
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
|
||||
{
|
||||
hostFromSchool = schoolLocation.Host;
|
||||
school = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) &&
|
||||
!string.IsNullOrWhiteSpace(querySchool)
|
||||
? querySchool.ToString().Trim()
|
||||
: schoolLocation.Host.Split('.')[0];
|
||||
}
|
||||
|
||||
string host;
|
||||
if (!string.IsNullOrWhiteSpace(hostValue))
|
||||
{
|
||||
if (!TryParseLocation(hostValue, out var hostLocation))
|
||||
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
|
||||
host = hostLocation.Host;
|
||||
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
|
||||
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
|
||||
if (LooksLikeLocation(schoolValue) &&
|
||||
QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) &&
|
||||
!string.IsNullOrWhiteSpace(querySchool))
|
||||
school = querySchool.ToString().Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
host = hostFromSchool ?? $"{school}.webuntis.com";
|
||||
}
|
||||
|
||||
if (school.Length == 0)
|
||||
throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt oder ist ungültig.");
|
||||
return (school, host);
|
||||
}
|
||||
|
||||
private static bool LooksLikeLocation(string value) => value.Contains('.') || value.Contains('/') ||
|
||||
value.StartsWith("http:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("https:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryParseLocation(string value, out Uri location)
|
||||
{
|
||||
var candidate = value.Trim();
|
||||
if (!candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
candidate = $"https://{candidate}";
|
||||
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host);
|
||||
}
|
||||
|
||||
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public sealed record WebUntisConnectRequest(
|
||||
string School,
|
||||
string? Host,
|
||||
string Username,
|
||||
string Password);
|
||||
|
||||
public sealed record WebUntisConnectionStatus(bool Connected, string? School, string? Username);
|
||||
|
||||
/// <summary>
|
||||
/// Hält persönliche WebUntis-Verbindungen ausschließlich im Arbeitsspeicher des Servers.
|
||||
/// Passwörter werden weder protokolliert noch serverseitig persistiert. Der enthaltene Client
|
||||
/// verwaltet die eigentliche WebUntis-Session und meldet sie nach Inaktivität wieder ab.
|
||||
/// </summary>
|
||||
public sealed class WebUntisConnectionStore(IHttpClientFactory httpClientFactory) : IAsyncDisposable
|
||||
{
|
||||
private sealed record Entry(WebUntisClient Client, string School, string Username);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _connections = new();
|
||||
|
||||
public WebUntisClient? GetClient(string userId) =>
|
||||
_connections.TryGetValue(userId, out var entry) ? entry.Client : null;
|
||||
|
||||
public WebUntisConnectionStatus GetStatus(string userId) =>
|
||||
_connections.TryGetValue(userId, out var entry)
|
||||
? new(true, entry.School, entry.Username)
|
||||
: new(false, null, null);
|
||||
|
||||
public async Task<WebUntisConnectionStatus> ConnectAsync(string userId, WebUntisConnectRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new WebUntisOptions
|
||||
{
|
||||
School = request.School,
|
||||
Host = request.Host ?? "",
|
||||
Username = request.Username,
|
||||
Password = request.Password,
|
||||
Client = "LehrerApp",
|
||||
SessionIdleTimeoutMinutes = 10,
|
||||
};
|
||||
var client = new WebUntisClient(httpClientFactory.CreateClient("webuntis"), Options.Create(options));
|
||||
try
|
||||
{
|
||||
// Authentifiziert wirklich gegen WebUntis; ungültige Daten werden nicht gespeichert.
|
||||
await client.GetSchoolYearsAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var entry = new Entry(client, request.School.Trim(), request.Username.Trim());
|
||||
if (_connections.TryGetValue(userId, out var previous))
|
||||
await previous.Client.DisposeAsync();
|
||||
_connections[userId] = entry;
|
||||
return new(true, entry.School, entry.Username);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string userId)
|
||||
{
|
||||
if (_connections.TryRemove(userId, out var entry)) await entry.Client.DisposeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
var entries = _connections.Values.ToList();
|
||||
_connections.Clear();
|
||||
foreach (var entry in entries) await entry.Client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user