WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s

Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 14:41:41 +02:00
co-authored by Claude Sonnet 5
parent 216708e879
commit 0a419c4c11
3 changed files with 188 additions and 55 deletions
@@ -445,6 +445,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount; [ObservableProperty] private int _ownDocumentationCriticalCount;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
public bool HasSecondaryRoster => SecondaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
@@ -526,13 +533,41 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
ApplyRosterFilter(); ApplyRosterFilter();
} }
private void ResetRosterState()
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
public void PrepareForDisplay()
{
if (Busy) return;
HomeroomClassName = _settings.HomeroomClassName;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
}
else
{
DetailsTab.Initialize(HomeroomClassName!);
CasesTab.Initialize(HomeroomClassName!);
if (StudentCount == 0)
Status = "Noch nicht geladen auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
}
NotifyRosterState();
}
[RelayCommand] [RelayCommand]
private async Task Load() private async Task Load()
{ {
HomeroomClassName = _settings.HomeroomClassName; HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0; ResetRosterState();
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
if (!HomeroomClassConfigured) if (!HomeroomClassConfigured)
{ {
Status = "Noch keine Klasse ausgewählt."; Status = "Noch keine Klasse ausgewählt.";
@@ -547,14 +582,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
NotifyRosterState(); NotifyRosterState();
try try
{ {
var today = DateOnly.FromDateTime(DateTime.Today);
var sevenDayStart = today.AddDays(-6);
var trendDays = LastSchoolDays(today, 7);
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die // Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf. // Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) — // UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht. // teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge // Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
@@ -568,54 +600,93 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var holidaysTask = GetHolidaysAsync(); var holidaysTask = GetHolidaysAsync();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); _lastStudents = studentsTask.Result;
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); _lastAbsences = absencesTask.Result;
var termStart = EstimateTermStart(absenceDaysYear, yearStart); _lastClassRegisterEvents = classRegisterTask.Result;
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); _lastHolidays = holidaysTask.Result;
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
var recentRegisterEntries = classRegisterTask.Result
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
var localStudents = _students.GetAll();
var closedVorgaengeByNameKey = studentsTask.Result
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.ToDictionary(
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterTask.Result,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
LateCount = Roster.Count(r => r.IsLate);
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = recentRegisterEntries.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
} }
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); } finally { Busy = false; NotifyRosterState(); }
} }
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
// wenn die Nutzerin sich dafür entscheidet.
public void RefreshFromLocalDataOnly()
{
if (Busy || _lastStudents is null || _lastAbsences is null ||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
ResetRosterState();
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
today, yearStart, sevenDayStart, trendDays);
NotifyRosterState();
}
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
}
private void BuildRosterFromCachedData(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
IReadOnlyList<CachedUntisHoliday> holidays,
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
{
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
var recentRegisterEntries = classRegisterEvents
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
var localStudents = _students.GetAll();
var closedVorgaengeByNameKey = students
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.ToDictionary(
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
LateCount = Roster.Count(r => r.IsLate);
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = recentRegisterEntries.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today);
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break; case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm: case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break; // RefreshFromLocalDataOnly() statt LoadCommand: baut die Ansicht aus den zuletzt
// geholten WebUntis-Daten + aktuellen lokalen Daten neu auf, löst aber selbst nie einen
// WebUntis-Request aus - den holt bewusst ausschließlich der "Aktualisieren"-Button.
case ClassTeacherOverviewViewModel vm: vm.RefreshFromLocalDataOnly(); break;
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break; case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
@@ -176,7 +179,11 @@ public partial class MainWindowViewModel : ObservableObject
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>(); var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; }; vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; }; vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; };
vm.LoadCommand.Execute(null); // Kein automatischer WebUntis-Abruf beim Navigieren: PrepareForDisplay() baut nur den
// lokalen Grundzustand auf, die eigentlichen Report-Daten holt die Nutzerin bewusst über
// den "Aktualisieren"-Button (LoadCommand) - vermeidet WebUntis-Login+Report-Latenz beim
// bloßen Öffnen der Seite.
vm.PrepareForDisplay();
return vm; return vm;
} }
+59 -4
View File
@@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true); private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2); private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(2);
// Feste (nicht zufällige) kurze Pause zwischen Login und erstem Report-Request, damit beides
// nicht im Millisekundenabstand hintereinander passiert.
private static readonly TimeSpan PostLoginDelay = TimeSpan.FromSeconds(1);
// WebUntis blockt Anfragen ohne die Header, die die eigene WebApp immer mitschickt, inzwischen
// serverseitig mit HTTP 403. Header-Satz per "Copy as cURL" aus einem echten Browser-Request
// 1:1 übernommen (kein X-Requested-With, WebUntis' Angular-Frontend sendet das nicht).
private const string BrowserUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.2 Safari/605.1.15";
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly WebUntisOptions _options; private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1); private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer; private readonly Timer _sessionExpiryTimer;
private string? _sessionId; private string? _sessionId;
private int? _myTeacherUntisId; private int? _myTeacherUntisId;
// Cookies, die WebUntis beim Login per Set-Cookie mitschickt (z.B. Tenant-Id, traceId) und die
// die WebApp bei jedem weiteren Request 1:1 zurücksendet. JSESSIONID wird separat verwaltet
// (siehe _sessionId), deshalb hier ausgeklammert.
private string? _serverCookies;
private string? _tenantId;
private DateTimeOffset _sessionExpiresAt; private DateTimeOffset _sessionExpiresAt;
private int _activeRequests; private int _activeRequests;
private bool _disposed; private bool _disposed;
@@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable
user = configuration.Username, user = configuration.Username,
password = configuration.Password, password = configuration.Password,
client = configuration.Client, client = configuration.Client,
}, null, cancellationToken); }, null, cancellationToken, CaptureServerCookies);
_sessionId = OptionalString(result, "sessionId") _sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException( ?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " + $"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " + $"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen."); "Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
_myTeacherUntisId = OptionalInt(result, "personId"); _myTeacherUntisId = OptionalInt(result, "personId");
await Task.Delay(PostLoginDelay, cancellationToken);
} }
_activeRequests++; _activeRequests++;
@@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30)); TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId, private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
{ {
var configuration = GetConfiguration(); var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}"; var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
@@ -433,9 +448,11 @@ public sealed class WebUntisClient : IAsyncDisposable
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" }, Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions), options: JsonOptions),
}; };
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}"); ApplyBrowserHeaders(request, configuration.Host);
if (sessionId is not null) request.Headers.Add("Cookie", SessionCookie(sessionId));
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken); using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
onResponse?.Invoke(response);
var payload = await ReadJsonAsync(response, cancellationToken); var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}"); throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
@@ -521,10 +538,48 @@ public sealed class WebUntisClient : IAsyncDisposable
{ {
var request = new HttpRequestMessage(HttpMethod.Get, uri); var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\""); ApplyBrowserHeaders(request, GetConfiguration().Host);
request.Headers.Add("Cookie", SessionCookie(sessionId));
if (_tenantId is not null) request.Headers.Add("Tenant-Id", _tenantId);
return request; return request;
} }
private string SessionCookie(string sessionId)
{
var cookie = $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"";
return _serverCookies is null ? cookie : $"{cookie}; {_serverCookies}";
}
// Sammelt Set-Cookie-Werte aus der Login-Antwort ein (z.B. Tenant-Id, traceId), die WebUntis'
// WebApp danach bei jedem Request unverändert zurückschickt. JSESSIONID wird ausgeklammert, da
// sie bereits separat über _sessionId verwaltet wird.
private void CaptureServerCookies(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) return;
var pairs = new List<string>();
foreach (var header in setCookieHeaders)
{
var nameValue = header.Split(';', 2)[0].Trim();
var name = nameValue.Split('=', 2)[0];
if (name.Equals("JSESSIONID", StringComparison.OrdinalIgnoreCase)) continue;
pairs.Add(nameValue);
if (name.Equals("Tenant-Id", StringComparison.OrdinalIgnoreCase) &&
nameValue.Split('=', 2) is [_, var value])
_tenantId = value.Trim('"');
}
_serverCookies = pairs.Count > 0 ? string.Join("; ", pairs) : null;
}
private static void ApplyBrowserHeaders(HttpRequestMessage request, string host)
{
request.Headers.UserAgent.ParseAdd(BrowserUserAgent);
request.Headers.Referrer = new Uri($"https://{host}/WebUntis/embedded.do?showSidebar=true");
request.Headers.Add("Accept-Language", "de-DE,de;q=0.9");
request.Headers.Add("Sec-Fetch-Site", "same-origin");
request.Headers.Add("Sec-Fetch-Mode", "cors");
request.Headers.Add("Sec-Fetch-Dest", "empty");
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout, private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {