|
|
|
@@ -0,0 +1,685 @@
|
|
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Net.Http.Json;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
|
|
|
|
namespace LehrerApp.WebUntis;
|
|
|
|
|
|
|
|
|
|
public sealed class WebUntisClient : IAsyncDisposable
|
|
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
|
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
|
|
|
|
private readonly HttpClient _http;
|
|
|
|
|
private readonly WebUntisOptions _options;
|
|
|
|
|
private readonly SemaphoreSlim _sessionGate = new(1, 1);
|
|
|
|
|
private readonly Timer _sessionExpiryTimer;
|
|
|
|
|
private string? _sessionId;
|
|
|
|
|
private DateTimeOffset _sessionExpiresAt;
|
|
|
|
|
private int _activeRequests;
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
|
|
|
|
|
public WebUntisClient(HttpClient http, WebUntisOptions options)
|
|
|
|
|
{
|
|
|
|
|
_http = http;
|
|
|
|
|
_options = options;
|
|
|
|
|
_sessionExpiryTimer = new Timer(
|
|
|
|
|
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
|
|
|
|
|
this,
|
|
|
|
|
Timeout.InfiniteTimeSpan,
|
|
|
|
|
Timeout.InfiniteTimeSpan);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisSchoolYear>> GetSchoolYearsAsync(CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültige Schuljahr-Liste geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisSchoolYear>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var id = RequiredInt(entry, "id");
|
|
|
|
|
var name = OptionalString(entry, "name")
|
|
|
|
|
?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen.");
|
|
|
|
|
return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
|
|
|
|
|
}).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisClass>> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId,
|
|
|
|
|
cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisClass>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var id = RequiredInt(entry, "id");
|
|
|
|
|
var name = OptionalString(entry, "name")
|
|
|
|
|
?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen.");
|
|
|
|
|
return new UntisClass(id, name, OptionalString(entry, "longName"),
|
|
|
|
|
OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"),
|
|
|
|
|
OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2"));
|
|
|
|
|
}).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisTeacher>> GetTeachersAsync(CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültige Lehrer-Liste geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisTeacher>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var id = RequiredInt(entry, "id");
|
|
|
|
|
var name = OptionalString(entry, "name")
|
|
|
|
|
?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel.");
|
|
|
|
|
var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array
|
|
|
|
|
? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
|
|
|
|
|
.Select(value => value!.Value).Distinct().Order().ToList()
|
|
|
|
|
: [];
|
|
|
|
|
return new UntisTeacher(id, name, OptionalString(entry, "foreName"),
|
|
|
|
|
OptionalString(entry, "longName"), OptionalString(entry, "title"),
|
|
|
|
|
OptionalBoolean(entry, "active"), departments);
|
|
|
|
|
}).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisHoliday>> GetHolidaysAsync(CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültige Ferien-Liste geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisHoliday>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var id = RequiredInt(entry, "id");
|
|
|
|
|
var name = OptionalString(entry, "name")
|
|
|
|
|
?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen.");
|
|
|
|
|
return new UntisHoliday(id, name, OptionalString(entry, "longName"),
|
|
|
|
|
RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
|
|
|
|
|
}).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisTimeGridDay>> GetTimeGridAsync(CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültigen Zeitraster-Daten geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisTimeGridDay>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array
|
|
|
|
|
? values.EnumerateArray().Select(unit => new UntisTimeUnit(
|
|
|
|
|
OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime")))
|
|
|
|
|
.ToList()
|
|
|
|
|
: [];
|
|
|
|
|
return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone());
|
|
|
|
|
}).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
|
|
|
|
|
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var reportData = await RequestReportAsync(sessionId, cancellationToken)
|
|
|
|
|
?? await PollReportAsync(sessionId, cancellationToken);
|
|
|
|
|
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
|
|
|
|
|
var allStudents = WebUntisStudentReportParser.Parse(reportText);
|
|
|
|
|
var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim();
|
|
|
|
|
var students = normalizedFilter is null
|
|
|
|
|
? allStudents
|
|
|
|
|
: allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter,
|
|
|
|
|
StringComparison.OrdinalIgnoreCase)).ToList();
|
|
|
|
|
return new UntisStudentReport(students.Count, normalizedFilter, students);
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
|
|
|
|
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getSubstitutions", new
|
|
|
|
|
{
|
|
|
|
|
startDate,
|
|
|
|
|
endDate,
|
|
|
|
|
departmentId = departmentId ?? 0,
|
|
|
|
|
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert.");
|
|
|
|
|
|
|
|
|
|
return (IReadOnlyList<UntisSubstitution>)entries.Select(entry => new UntisSubstitution(
|
|
|
|
|
OptionalString(entry, "type") ?? "unknown",
|
|
|
|
|
OptionalInt(entry, "lsid"),
|
|
|
|
|
OptionalString(entry, "lstype"),
|
|
|
|
|
RequiredInt(entry, "date"),
|
|
|
|
|
RequiredInt(entry, "startTime"),
|
|
|
|
|
RequiredInt(entry, "endTime"),
|
|
|
|
|
OptionalString(entry, "txt"),
|
|
|
|
|
Entities(entry, "kl"),
|
|
|
|
|
Entities(entry, "te"),
|
|
|
|
|
Entities(entry, "su"),
|
|
|
|
|
Entities(entry, "ro"),
|
|
|
|
|
ParseReschedule(entry),
|
|
|
|
|
entry.Clone())).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisTimetablePeriod>> GetTimetableAsync(UntisTimetableElementType elementType,
|
|
|
|
|
int elementId, int startDate, int endDate, CancellationToken cancellationToken) =>
|
|
|
|
|
WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var options = new
|
|
|
|
|
{
|
|
|
|
|
element = new { id = elementId, type = (int)elementType },
|
|
|
|
|
startDate,
|
|
|
|
|
endDate,
|
|
|
|
|
showBooking = false,
|
|
|
|
|
showInfo = true,
|
|
|
|
|
showSubstText = true,
|
|
|
|
|
showLsText = true,
|
|
|
|
|
showLsNumber = true,
|
|
|
|
|
showStudentgroup = true,
|
|
|
|
|
};
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültigen Stundenplan-Daten geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisTimetablePeriod>)entries.Select(entry => new UntisTimetablePeriod(
|
|
|
|
|
RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"),
|
|
|
|
|
RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"),
|
|
|
|
|
OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"),
|
|
|
|
|
OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"),
|
|
|
|
|
Entities(entry, "ro"), entry.Clone())).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
|
|
|
|
|
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var result = await RpcAsync("getTimetableWithAbsences", new
|
|
|
|
|
{
|
|
|
|
|
options = new { startDate, endDate },
|
|
|
|
|
}, sessionId, cancellationToken);
|
|
|
|
|
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
|
|
|
|
|
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
|
|
|
|
|
|
|
|
|
|
var absences = periods.EnumerateArray()
|
|
|
|
|
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
|
|
|
|
|
.Select(entry => new UntisStudentAbsence(
|
|
|
|
|
RequiredInt(entry, "studentId"),
|
|
|
|
|
RequiredInt(entry, "date"),
|
|
|
|
|
RequiredInt(entry, "startTime"),
|
|
|
|
|
RequiredInt(entry, "endTime"),
|
|
|
|
|
OptionalInt(entry, "absentTime") ?? 0,
|
|
|
|
|
OptionalBoolean(entry, "checked"),
|
|
|
|
|
OptionalString(entry, "absenceReason"),
|
|
|
|
|
OptionalString(entry, "excuseStatus"),
|
|
|
|
|
OptionalInt(entry, "subjectId"),
|
|
|
|
|
IntArray(entry, "teacherIds"),
|
|
|
|
|
OptionalString(entry, "studentGroup"),
|
|
|
|
|
entry.Clone()))
|
|
|
|
|
.OrderBy(entry => entry.Date)
|
|
|
|
|
.ThenBy(entry => entry.StartTime)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
|
|
|
|
|
absences.Sum(entry => entry.AbsentMinutes), absences);
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
|
|
|
|
|
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getClassregEvents", new
|
|
|
|
|
{
|
|
|
|
|
startDate,
|
|
|
|
|
endDate,
|
|
|
|
|
id = studentId,
|
|
|
|
|
type = (int)UntisTimetableElementType.Student,
|
|
|
|
|
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert.");
|
|
|
|
|
|
|
|
|
|
return (IReadOnlyList<UntisClassRegisterEntry>)entries.Select(entry =>
|
|
|
|
|
{
|
|
|
|
|
var surname = OptionalString(entry, "surname");
|
|
|
|
|
var foreName = OptionalString(entry, "forname");
|
|
|
|
|
var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null));
|
|
|
|
|
return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName,
|
|
|
|
|
displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"),
|
|
|
|
|
OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"),
|
|
|
|
|
OptionalString(entry, "text"), entry.Clone());
|
|
|
|
|
}).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisClassRegisterCategory>> GetClassRegisterCategoriesAsync(
|
|
|
|
|
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken),
|
|
|
|
|
"WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisClassRegisterCategory>)entries.Select(entry =>
|
|
|
|
|
new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "",
|
|
|
|
|
OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<UntisClassRegisterCategoryGroup>> GetClassRegisterCategoryGroupsAsync(
|
|
|
|
|
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
|
|
|
|
{
|
|
|
|
|
var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId,
|
|
|
|
|
cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert.");
|
|
|
|
|
return (IReadOnlyList<UntisClassRegisterCategoryGroup>)entries.Select(entry =>
|
|
|
|
|
new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"),
|
|
|
|
|
OptionalString(entry, "name") ?? "", entry.Clone())).ToList();
|
|
|
|
|
}, cancellationToken);
|
|
|
|
|
|
|
|
|
|
private async Task<T> WithSessionAsync<T>(Func<string, Task<T>> action, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
await using var lease = await AcquireSessionAsync(cancellationToken);
|
|
|
|
|
return await action(lease.SessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<SessionLease> AcquireSessionAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
await _sessionGate.WaitAsync(cancellationToken);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
|
_sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
|
|
|
|
|
|
|
|
|
// Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits
|
|
|
|
|
// abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden.
|
|
|
|
|
if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow)
|
|
|
|
|
{
|
|
|
|
|
var expiredSessionId = _sessionId;
|
|
|
|
|
_sessionId = null;
|
|
|
|
|
await TryLogoutAsync(expiredSessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_sessionId is null)
|
|
|
|
|
{
|
|
|
|
|
var configuration = GetConfiguration();
|
|
|
|
|
var result = await RpcAsync("authenticate", new
|
|
|
|
|
{
|
|
|
|
|
user = configuration.Username,
|
|
|
|
|
password = configuration.Password,
|
|
|
|
|
client = configuration.Client,
|
|
|
|
|
}, null, cancellationToken);
|
|
|
|
|
_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.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_activeRequests++;
|
|
|
|
|
return new SessionLease(this, _sessionId);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_sessionGate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async ValueTask ReleaseSessionAsync()
|
|
|
|
|
{
|
|
|
|
|
await _sessionGate.WaitAsync();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (_activeRequests > 0) _activeRequests--;
|
|
|
|
|
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
|
|
|
|
|
|
|
|
|
|
var timeout = SessionIdleTimeout;
|
|
|
|
|
_sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout);
|
|
|
|
|
_sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_sessionGate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task CloseExpiredSessionAsync()
|
|
|
|
|
{
|
|
|
|
|
string? sessionId = null;
|
|
|
|
|
await _sessionGate.WaitAsync();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
|
|
|
|
|
var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow;
|
|
|
|
|
if (remaining > TimeSpan.Zero)
|
|
|
|
|
{
|
|
|
|
|
_sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sessionId = _sessionId;
|
|
|
|
|
_sessionId = null;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_sessionGate.Release();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task TryLogoutAsync(string sessionId)
|
|
|
|
|
{
|
|
|
|
|
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
|
|
|
|
|
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
string? sessionId;
|
|
|
|
|
await _sessionGate.WaitAsync();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (_disposed) return;
|
|
|
|
|
_disposed = true;
|
|
|
|
|
sessionId = _sessionId;
|
|
|
|
|
_sessionId = null;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_sessionGate.Release();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
|
|
|
|
|
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
|
|
|
|
|
await _sessionExpiryTimer.DisposeAsync();
|
|
|
|
|
if (sessionId is not null) await TryLogoutAsync(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private TimeSpan SessionIdleTimeout =>
|
|
|
|
|
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
|
|
|
|
|
|
|
|
|
|
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var configuration = GetConfiguration();
|
|
|
|
|
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
|
|
|
|
|
{
|
|
|
|
|
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}");
|
|
|
|
|
|
|
|
|
|
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
|
|
|
|
var payload = await ReadJsonAsync(response, cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
|
|
|
|
|
if (payload.ValueKind != JsonValueKind.Object)
|
|
|
|
|
throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method}).");
|
|
|
|
|
if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
|
|
|
|
|
throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}");
|
|
|
|
|
if (!TryProperty(payload, "result", out var result))
|
|
|
|
|
throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis.");
|
|
|
|
|
return result.Clone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
|
|
|
|
|
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
|
|
|
|
|
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
|
|
|
|
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
|
|
|
|
var payload = await ReadJsonAsync(response, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
|
|
|
|
|
errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4))
|
|
|
|
|
return null;
|
|
|
|
|
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
|
|
|
|
|
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
|
|
|
|
|
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
|
|
|
|
|
if (!OptionalBoolean(data, "finished")) return null;
|
|
|
|
|
return ReportData.From(data);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<ReportData> PollReportAsync(string sessionId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT";
|
|
|
|
|
for (var attempt = 0; attempt < 60; attempt++)
|
|
|
|
|
{
|
|
|
|
|
using var request = ReportRequest(uri, sessionId, acceptJson: true);
|
|
|
|
|
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
|
|
|
|
|
var payload = await ReadJsonAsync(response, cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}.");
|
|
|
|
|
|
|
|
|
|
if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) &&
|
|
|
|
|
jobs.ValueKind == JsonValueKind.Array)
|
|
|
|
|
{
|
|
|
|
|
foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished")))
|
|
|
|
|
{
|
|
|
|
|
if (OptionalBoolean(job, "hasJobError"))
|
|
|
|
|
throw new WebUntisException("Report-Polling enthält einen Job-Fehler.");
|
|
|
|
|
if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<string> FetchReportTextAsync(string sessionId, ReportData reportData,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var uri = reportData.ReportParams is not null
|
|
|
|
|
? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}"
|
|
|
|
|
: $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}";
|
|
|
|
|
using var request = ReportRequest(uri, sessionId, acceptJson: false);
|
|
|
|
|
using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken);
|
|
|
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
|
|
|
if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
|
|
|
|
{
|
|
|
|
|
var payload = ParseJson(bytes);
|
|
|
|
|
throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}");
|
|
|
|
|
}
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}.");
|
|
|
|
|
try { return StrictUtf8.GetString(bytes); }
|
|
|
|
|
catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson)
|
|
|
|
|
{
|
|
|
|
|
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()}\"");
|
|
|
|
|
return request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
|
timeoutSource.CancelAfter(timeout);
|
|
|
|
|
try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); }
|
|
|
|
|
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception);
|
|
|
|
|
}
|
|
|
|
|
catch (HttpRequestException exception)
|
|
|
|
|
{
|
|
|
|
|
throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Config GetConfiguration()
|
|
|
|
|
{
|
|
|
|
|
var schoolValue = _options.School.Trim();
|
|
|
|
|
var username = _options.Username.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 (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 = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool
|
|
|
|
|
? querySchool
|
|
|
|
|
: 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) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool)
|
|
|
|
|
school = querySchool;
|
|
|
|
|
}
|
|
|
|
|
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 static string? QueryValue(Uri uri, string key)
|
|
|
|
|
{
|
|
|
|
|
foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
{
|
|
|
|
|
var pair = part.Split('=', 2);
|
|
|
|
|
if (!Uri.UnescapeDataString(pair[0]).Equals(key, StringComparison.OrdinalIgnoreCase)) continue;
|
|
|
|
|
return pair.Length == 2
|
|
|
|
|
? Uri.UnescapeDataString(pair[1].Replace('+', ' ')).Trim()
|
|
|
|
|
: "";
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
|
|
|
|
|
|
|
|
|
|
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
|
|
|
|
|
ParseJson(await response.Content.ReadAsByteArrayAsync(token));
|
|
|
|
|
|
|
|
|
|
private static JsonElement ParseJson(byte[] bytes)
|
|
|
|
|
{
|
|
|
|
|
try { return JsonSerializer.Deserialize<JsonElement>(bytes, JsonOptions); }
|
|
|
|
|
catch (JsonException) { return default; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<JsonElement> RequireArray(JsonElement value, string error) =>
|
|
|
|
|
value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList()
|
|
|
|
|
: throw new WebUntisException(error);
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<UntisEntity> Entities(JsonElement parent, string property) =>
|
|
|
|
|
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
|
|
|
|
|
? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0,
|
|
|
|
|
OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"),
|
|
|
|
|
OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList()
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<int> IntArray(JsonElement parent, string property) =>
|
|
|
|
|
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
|
|
|
|
|
? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
|
|
|
|
|
.Select(value => value!.Value).ToList()
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
private static UntisReschedule? ParseReschedule(JsonElement parent)
|
|
|
|
|
{
|
|
|
|
|
if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null;
|
|
|
|
|
var date = OptionalInt(value, "date");
|
|
|
|
|
var start = OptionalInt(value, "startTime");
|
|
|
|
|
var end = OptionalInt(value, "endTime");
|
|
|
|
|
return date is not null && start is not null && end is not null
|
|
|
|
|
? new UntisReschedule(date.Value, start.Value, end.Value)
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool TryProperty(JsonElement value, string name, out JsonElement property)
|
|
|
|
|
{
|
|
|
|
|
property = default;
|
|
|
|
|
return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property)
|
|
|
|
|
?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig.");
|
|
|
|
|
|
|
|
|
|
private static int? OptionalInt(JsonElement parent, string property) =>
|
|
|
|
|
TryProperty(parent, property, out var value) ? OptionalInt(value) : null;
|
|
|
|
|
|
|
|
|
|
private static int? OptionalInt(JsonElement value)
|
|
|
|
|
{
|
|
|
|
|
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
|
|
|
|
|
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? OptionalString(JsonElement parent, string property)
|
|
|
|
|
{
|
|
|
|
|
if (!TryProperty(parent, property, out var value)) return null;
|
|
|
|
|
var text = value.ValueKind == JsonValueKind.String ? value.GetString() :
|
|
|
|
|
value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null;
|
|
|
|
|
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool OptionalBoolean(JsonElement parent, string property)
|
|
|
|
|
{
|
|
|
|
|
if (!TryProperty(parent, property, out var value)) return false;
|
|
|
|
|
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean();
|
|
|
|
|
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0;
|
|
|
|
|
return value.ValueKind == JsonValueKind.String &&
|
|
|
|
|
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
|
|
|
|
|
{
|
|
|
|
|
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
|
|
|
|
|
if (payload.ValueKind == JsonValueKind.Object)
|
|
|
|
|
{
|
|
|
|
|
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
|
|
|
|
|
return errorMessage;
|
|
|
|
|
if (OptionalString(payload, "message") is { } message) return message;
|
|
|
|
|
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
|
|
|
|
|
return dataMessage;
|
|
|
|
|
}
|
|
|
|
|
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record Config(string School, string Host, string Username, string Password, string Client);
|
|
|
|
|
|
|
|
|
|
private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable
|
|
|
|
|
{
|
|
|
|
|
private int _released;
|
|
|
|
|
public string SessionId { get; } = sessionId;
|
|
|
|
|
|
|
|
|
|
public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0
|
|
|
|
|
? owner.ReleaseSessionAsync()
|
|
|
|
|
: ValueTask.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record ReportData(string? ReportParams, string? MessageId)
|
|
|
|
|
{
|
|
|
|
|
public static ReportData From(JsonElement data)
|
|
|
|
|
{
|
|
|
|
|
var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId"));
|
|
|
|
|
return result.ReportParams is null && result.MessageId is null
|
|
|
|
|
? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.")
|
|
|
|
|
: result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|