WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
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:
@@ -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,28 +600,71 @@ 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;
|
||||||
|
_lastAbsences = absencesTask.Result;
|
||||||
|
_lastClassRegisterEvents = classRegisterTask.Result;
|
||||||
|
_lastHolidays = holidaysTask.Result;
|
||||||
|
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
|
||||||
|
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
|
||||||
|
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; 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 todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
|
||||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||||
var recentRegisterEntries = classRegisterTask.Result
|
var recentRegisterEntries = classRegisterEvents
|
||||||
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
.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/
|
// 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
|
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
|
||||||
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
||||||
var localStudents = _students.GetAll();
|
var localStudents = _students.GetAll();
|
||||||
var closedVorgaengeByNameKey = studentsTask.Result
|
var closedVorgaengeByNameKey = students
|
||||||
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
||||||
.Where(x => x.Student is not null)
|
.Where(x => x.Student is not null)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
||||||
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
||||||
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
||||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
|
||||||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||||
holidayWeekdaysExcluded, termStart, classRegisterTask.Result,
|
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||||
|
|
||||||
StudentCount = Roster.Count;
|
StudentCount = Roster.Count;
|
||||||
@@ -607,14 +682,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
BuildVorgangRegressionNotices();
|
BuildVorgangRegressionNotices();
|
||||||
BuildOwnDocumentationCounts();
|
BuildOwnDocumentationCounts();
|
||||||
BuildOpenExcuses(absenceDaysYear, today);
|
BuildOpenExcuses(absenceDaysYear, today);
|
||||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
|
||||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||||
ApplyRosterFilter();
|
ApplyRosterFilter();
|
||||||
NotifySummary();
|
NotifySummary();
|
||||||
}
|
}
|
||||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
|
||||||
finally { Busy = false; NotifyRosterState(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user