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 _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 HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||||
@@ -526,13 +533,41 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
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]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
ResetRosterState();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
@@ -547,14 +582,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
NotifyRosterState();
|
||||
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
|
||||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||||
// 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 absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
|
||||
@@ -568,54 +600,93 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
var holidaysTask = GetHolidaysAsync();
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
|
||||
|
||||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
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);
|
||||
_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}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
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 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 ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
|
||||
@@ -90,7 +90,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
case TimetableViewModel vm: vm.Load(); break;
|
||||
case WorkloadViewModel vm:
|
||||
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 GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||
@@ -176,7 +179,11 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,26 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
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 WebUntisOptions _options;
|
||||
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
||||
private readonly Timer _sessionExpiryTimer;
|
||||
private string? _sessionId;
|
||||
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 int _activeRequests;
|
||||
private bool _disposed;
|
||||
@@ -330,13 +344,14 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
user = configuration.Username,
|
||||
password = configuration.Password,
|
||||
client = configuration.Client,
|
||||
}, null, cancellationToken);
|
||||
}, null, cancellationToken, CaptureServerCookies);
|
||||
_sessionId = OptionalString(result, "sessionId")
|
||||
?? 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.");
|
||||
_myTeacherUntisId = OptionalInt(result, "personId");
|
||||
await Task.Delay(PostLoginDelay, cancellationToken);
|
||||
}
|
||||
|
||||
_activeRequests++;
|
||||
@@ -424,7 +439,7 @@ public sealed class WebUntisClient : IAsyncDisposable
|
||||
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
|
||||
|
||||
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken, Action<HttpResponseMessage>? onResponse = null)
|
||||
{
|
||||
var configuration = GetConfiguration();
|
||||
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" },
|
||||
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);
|
||||
onResponse?.Invoke(response);
|
||||
var payload = await ReadJsonAsync(response, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user