feat: CalDAV server with app password authentication
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77ab48a954
commit
88a6375d96
12 files changed
+1622
-10
No files matched your search
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FamilyPlanner.Infrastructure.Identity;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.CalDav;
|
||||
|
||||
public sealed class CalDavOptions
|
||||
{
|
||||
public const string SectionName = "CalDav";
|
||||
|
||||
/// <summary>Basic-Auth nur über HTTPS annehmen (ADR-0017). Nur für lokale Entwicklung/Tests abschalten.</summary>
|
||||
public bool RequireHttps { get; set; } = true;
|
||||
|
||||
/// <summary>Fehlversuche je IP und Fenster, bevor weitere Versuche gesperrt werden.</summary>
|
||||
public int MaxFailuresPerIp { get; set; } = 30;
|
||||
|
||||
/// <summary>Fehlversuche je IP + Benutzername und Fenster.</summary>
|
||||
public int MaxFailuresPerUser { get; set; } = 8;
|
||||
|
||||
public int FailureWindowMinutes { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bremst das Raten von App-Passwörtern: zählt Fehlversuche je IP und je IP + Benutzername in einem Zeitfenster. Bewusst nicht je
|
||||
/// Benutzername allein, damit Fremde niemanden aussperren können. Ein normales Anfragelimit ist ungeeignet, weil iOS bei der ersten
|
||||
/// Synchronisation viele Anfragen schnell hintereinander stellt.
|
||||
/// </summary>
|
||||
public sealed class CalDavFailureThrottle(TimeProvider timeProvider, IOptions<CalDavOptions> options)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (int Count, DateTimeOffset WindowStart)> _failures = new();
|
||||
|
||||
private TimeSpan Window => TimeSpan.FromMinutes(Math.Max(1, options.Value.FailureWindowMinutes));
|
||||
|
||||
/// <summary>Wie lange noch gesperrt? <c>null</c>, wenn der Versuch zulässig ist.</summary>
|
||||
public TimeSpan? BlockedFor(string ip, string userName)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Max(Remaining(IpKey(ip), options.Value.MaxFailuresPerIp, now), Remaining(UserKey(ip, userName), options.Value.MaxFailuresPerUser, now));
|
||||
}
|
||||
|
||||
public void RegisterFailure(string ip, string userName)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
Increment(IpKey(ip), now);
|
||||
Increment(UserKey(ip, userName), now);
|
||||
if (_failures.Count > 10_000)
|
||||
{
|
||||
foreach (var (key, value) in _failures)
|
||||
{
|
||||
if (now - value.WindowStart >= Window)
|
||||
{
|
||||
_failures.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset(string ip, string userName) => _failures.TryRemove(UserKey(ip, userName), out _);
|
||||
|
||||
private static string IpKey(string ip) => "ip|" + ip;
|
||||
|
||||
private static string UserKey(string ip, string userName) => "user|" + ip + "|" + userName.ToUpperInvariant();
|
||||
|
||||
private static TimeSpan? Max(TimeSpan? a, TimeSpan? b) => (a, b) switch
|
||||
{
|
||||
(null, _) => b,
|
||||
(_, null) => a,
|
||||
_ => a > b ? a : b,
|
||||
};
|
||||
|
||||
private TimeSpan? Remaining(string key, int limit, DateTimeOffset now)
|
||||
{
|
||||
if (!_failures.TryGetValue(key, out var entry) || entry.Count < limit)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remaining = entry.WindowStart + Window - now;
|
||||
return remaining > TimeSpan.Zero ? remaining : null;
|
||||
}
|
||||
|
||||
private void Increment(string key, DateTimeOffset now) =>
|
||||
_failures.AddOrUpdate(
|
||||
key,
|
||||
_ => (1, now),
|
||||
(_, old) => now - old.WindowStart >= Window ? (1, now) : (old.Count + 1, old.WindowStart));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP Basic mit App-Passwort für <c>/caldav</c> (ADR-0017). Eigenes Schema, nicht Standard: Cookies gelten hier nicht, und ein
|
||||
/// App-Passwort öffnet nie die <c>/api</c>-Endpunkte. Das Passwort des Kontos wird nicht akzeptiert.
|
||||
/// </summary>
|
||||
public sealed class CalDavBasicHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> optionsMonitor,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
AppDbContext db,
|
||||
ILookupNormalizer normalizer,
|
||||
TimeProvider timeProvider,
|
||||
CalDavFailureThrottle throttle,
|
||||
IOptions<CalDavOptions> calDavOptions)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(optionsMonitor, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "CalDavBasic";
|
||||
public const string Realm = "FamilyPlanner";
|
||||
private const string RejectionKey = "caldav.rejection";
|
||||
private static readonly TimeSpan LastUsedInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
private enum Rejection
|
||||
{
|
||||
Credentials,
|
||||
HttpsRequired,
|
||||
Throttled,
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var header) || !TryDecode(header.ToString(), out var userName, out var secret))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
if (calDavOptions.Value.RequireHttps && !Request.IsHttps)
|
||||
{
|
||||
Context.Items[RejectionKey] = Rejection.HttpsRequired;
|
||||
return AuthenticateResult.Fail("HTTPS erforderlich");
|
||||
}
|
||||
|
||||
var ip = Context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
if (throttle.BlockedFor(ip, userName) is { } blockedFor)
|
||||
{
|
||||
Context.Items[RejectionKey] = Rejection.Throttled;
|
||||
Response.Headers.RetryAfter = Math.Ceiling(blockedFor.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||
return AuthenticateResult.Fail("Zu viele Fehlversuche");
|
||||
}
|
||||
|
||||
var principal = await TryAuthenticateAsync(userName, secret);
|
||||
if (principal is null)
|
||||
{
|
||||
throttle.RegisterFailure(ip, userName);
|
||||
Context.Items[RejectionKey] = Rejection.Credentials;
|
||||
return AuthenticateResult.Fail("Ungültige Zugangsdaten");
|
||||
}
|
||||
|
||||
throttle.Reset(ip, userName);
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
var rejection = Context.Items.TryGetValue(RejectionKey, out var value) && value is Rejection r ? r : Rejection.Credentials;
|
||||
switch (rejection)
|
||||
{
|
||||
case Rejection.HttpsRequired:
|
||||
Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
break;
|
||||
case Rejection.Throttled:
|
||||
Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
break;
|
||||
default:
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
Response.Headers.WWWAuthenticate = $"Basic realm=\"{Realm}\", charset=\"UTF-8\"";
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Liest <c>Basic base64(Benutzer:Passwort)</c>; der Benutzername darf kein <c>:</c> enthalten (Identity erlaubt es nicht).</summary>
|
||||
internal static bool TryDecode(string header, out string userName, out string secret)
|
||||
{
|
||||
userName = secret = string.Empty;
|
||||
const string prefix = "Basic ";
|
||||
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header[prefix.Length..].Trim()));
|
||||
var separator = decoded.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator <= 0 || separator == decoded.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(userName, secret) = (decoded[..separator], decoded[(separator + 1)..]);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ClaimsPrincipal?> TryAuthenticateAsync(string userName, string secret)
|
||||
{
|
||||
// Falsche Länge: gar nicht erst die Datenbank fragen (das normale Passwort wird so ohnehin nie geprüft).
|
||||
if (!CalDavSecret.IsWellFormed(secret))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var normalized = normalizer.NormalizeName(userName);
|
||||
var user = await db.Users.AsNoTracking().SingleOrDefaultAsync(u => u.NormalizedUserName == normalized, Context.RequestAborted);
|
||||
if (user is null || !user.CanSignIn(now) || user.MustChangePassword)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hash = CalDavSecret.Hash(secret);
|
||||
var credential = await db.CalDavCredentials.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.SecretHash == hash && c.UserId == user.Id && c.RevokedAt == null, Context.RequestAborted);
|
||||
if (credential is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var roles = await (from userRole in db.UserRoles
|
||||
join role in db.Roles on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == user.Id
|
||||
select role.Name).ToListAsync(Context.RequestAborted);
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (credential.LastUsedAt is null || now - credential.LastUsedAt > LastUsedInterval)
|
||||
{
|
||||
var client = Request.Headers.UserAgent.ToString();
|
||||
client = client.Length > 100 ? client[..100] : client;
|
||||
await db.CalDavCredentials.Where(c => c.Id == credential.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(c => c.LastUsedAt, now).SetProperty(c => c.LastUsedClient, client), Context.RequestAborted);
|
||||
}
|
||||
|
||||
// Dieselben Claims wie beim Cookie-Login (AppClaimsPrincipalFactory), damit Berechtigungen, Kalenderrechte und das Audit-Feld
|
||||
// CreatedBy unverändert funktionieren.
|
||||
var identity = new ClaimsIdentity(SchemeName, ClaimTypes.Name, ClaimTypes.Role);
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
|
||||
identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName ?? userName));
|
||||
identity.AddClaim(new Claim(ApiConventions.DisplayNameClaim, user.DisplayName));
|
||||
foreach (var role in roles)
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.Role, role!));
|
||||
}
|
||||
|
||||
if (user.ExpiresAt is { } expiresAt)
|
||||
{
|
||||
identity.AddClaim(new Claim(ApiConventions.ExpiresAtClaim, expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain.Audit;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Infrastructure.Audit;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.CalDav;
|
||||
|
||||
/// <summary>Verwaltung der App-Passwörter für Kalender-Apps durch den Benutzer selbst („Meine Einstellungen“, ADR-0017).</summary>
|
||||
public static class CalDavCredentialEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCalDavCredentialEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/caldav").WithTags("CalDAV").RequirePermission(Permission.CalendarRead);
|
||||
group.MapGet("/info", GetInfo);
|
||||
group.MapGet("/credentials", ListAsync);
|
||||
group.MapPost("/credentials", CreateAsync).Validate<CreateCalDavCredentialRequest>();
|
||||
group.MapDelete("/credentials/{id:guid}", RevokeAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static Ok<CalDavInfoDto> GetInfo(ClaimsPrincipal user, HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
return TypedResults.Ok(new CalDavInfoDto($"{request.Scheme}://{request.Host}{request.PathBase}{DavPath.RootPath}", user.Identity?.Name ?? string.Empty, request.IsHttps));
|
||||
}
|
||||
|
||||
internal static async Task<Ok<List<CalDavCredentialDto>>> ListAsync(ClaimsPrincipal user, AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
var items = await db.CalDavCredentials.AsNoTracking().Where(c => c.UserId == userId)
|
||||
.OrderBy(c => c.RevokedAt != null).ThenByDescending(c => c.CreatedAt).ToListAsync(ct);
|
||||
return TypedResults.Ok(items.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
internal static async Task<Results<Created<CalDavCredentialSecretDto>, ProblemHttpResult>> CreateAsync(
|
||||
CreateCalDavCredentialRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
|
||||
{
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
if (await db.CalDavCredentials.CountAsync(c => c.UserId == userId && c.RevokedAt == null, ct) >= CalDavCredential.MaxActivePerUser)
|
||||
{
|
||||
return EndpointHelpers.Conflict($"Es sind höchstens {CalDavCredential.MaxActivePerUser} App-Passwörter gleichzeitig möglich. Bitte zuerst nicht mehr genutzte widerrufen.");
|
||||
}
|
||||
|
||||
var secret = CalDavSecret.Generate();
|
||||
var credential = new CalDavCredential
|
||||
{
|
||||
UserId = userId,
|
||||
Label = request.Label.Trim(),
|
||||
SecretHash = CalDavSecret.Hash(secret),
|
||||
SecretPrefix = CalDavSecret.Prefix(secret),
|
||||
};
|
||||
db.CalDavCredentials.Add(credential);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(user.ActorName(), AuditActions.CalDavCredentialCreated, "CalDavCredential", credential.Id.ToString(), credential.Label, null, ct);
|
||||
|
||||
return TypedResults.Created($"/api/caldav/credentials/{credential.Id}", new CalDavCredentialSecretDto(ToDto(credential), secret));
|
||||
}
|
||||
|
||||
internal static async Task<NoContent> RevokeAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, TimeProvider timeProvider, CancellationToken ct)
|
||||
{
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
var credential = await db.CalDavCredentials.SingleOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
|
||||
if (credential is { RevokedAt: null })
|
||||
{
|
||||
credential.RevokedAt = timeProvider.GetUtcNow();
|
||||
credential.RevokedBy = userId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(user.ActorName(), AuditActions.CalDavCredentialRevoked, "CalDavCredential", credential.Id.ToString(), credential.Label, null, ct);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
internal static CalDavCredentialDto ToDto(CalDavCredential c) =>
|
||||
new(c.Id, c.Label, c.SecretPrefix, c.CreatedAt, c.LastUsedAt, c.LastUsedClient, c.RevokedAt);
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using FamilyPlanner.Api.Features.Calendar;
|
||||
using FamilyPlanner.Api.Features.Calendar.Ics;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
using FamilyPlanner.Shared;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using static FamilyPlanner.Api.Features.CalDav.CalDavXml;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.CalDav;
|
||||
|
||||
/// <summary>Antwort mit Statuscode, optionalem Körper und Headern – für WebDAV-Methoden, die Minimal APIs nicht kennen.</summary>
|
||||
internal sealed class DavResult(int status, string? body = null, string contentType = "application/xml; charset=utf-8", Action<IHeaderDictionary>? headers = null) : IResult
|
||||
{
|
||||
public async Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
var response = httpContext.Response;
|
||||
response.StatusCode = status;
|
||||
headers?.Invoke(response.Headers);
|
||||
if (body is not null && status is not (StatusCodes.Status204NoContent or StatusCodes.Status304NotModified))
|
||||
{
|
||||
response.ContentType = contentType;
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
response.ContentLength = bytes.Length;
|
||||
if (!HttpMethods.IsHead(httpContext.Request.Method))
|
||||
{
|
||||
await response.Body.WriteAsync(bytes, httpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimaler CalDAV-Server (RFC 4791, 6578; ADR-0017): Entdeckung, PROPFIND, REPORT (calendar-query, calendar-multiget, sync-collection),
|
||||
/// GET/PUT/DELETE von .ics-Terminen mit ETag-Prüfung. Zugriff per App-Passwort (HTTP Basic); die Rechte je Kalender und Benutzer
|
||||
/// (<see cref="CalendarAccessService"/>) werden bei jeder Anfrage serverseitig geprüft.
|
||||
/// </summary>
|
||||
public static class CalDavEndpoints
|
||||
{
|
||||
public const string PolicyName = "CalDav";
|
||||
private const string ContentTypeEvent = "text/calendar; charset=utf-8; component=VEVENT";
|
||||
private static readonly string[] Methods =
|
||||
["OPTIONS", "PROPFIND", "PROPPATCH", "REPORT", "GET", "HEAD", "PUT", "DELETE", "MKCOL", "MKCALENDAR", "MOVE", "COPY", "LOCK", "UNLOCK", "POST"];
|
||||
|
||||
private static readonly Regex UnsupportedComponent = new(@"^BEGIN:(VTODO|VJOURNAL|VFREEBUSY)\b", RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public static IEndpointRouteBuilder MapCalDavEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Entdeckung (RFC 6764): Clients fragen mit der Domain /.well-known/caldav, anonym.
|
||||
app.MapMethods("/.well-known/caldav", Methods, () => Results.Redirect(DavPath.RootPath, permanent: true))
|
||||
.AllowAnonymous()
|
||||
.ExcludeFromDescription();
|
||||
|
||||
app.MapMethods("/caldav", Methods, HandleAsync).RequireAuthorization(PolicyName).RequireRateLimiting(HostingSetup.CalDavRateLimitPolicy).ExcludeFromDescription();
|
||||
app.MapMethods("/caldav/{**path}", Methods, HandleAsync).RequireAuthorization(PolicyName).RequireRateLimiting(HostingSetup.CalDavRateLimitPolicy).ExcludeFromDescription();
|
||||
return app;
|
||||
}
|
||||
|
||||
private sealed record Ctx(
|
||||
HttpContext Http,
|
||||
Guid UserId,
|
||||
string UserName,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
IReadOnlyDictionary<Guid, CalendarWithAccess> Calendars,
|
||||
AppDbContext Db,
|
||||
string TimeZoneId,
|
||||
CancellationToken Ct);
|
||||
|
||||
/// <summary>Ein Eintrag einer Multistatus-Antwort: Adresse und (verzögert berechnete) Eigenschaften.</summary>
|
||||
private sealed record Node(string Href, IReadOnlyDictionary<XName, Func<XElement>> Props);
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext http,
|
||||
ClaimsPrincipal user,
|
||||
AppDbContext db,
|
||||
CalendarAccessService access,
|
||||
ISettingsProvider settingsProvider,
|
||||
CalendarNotifier notifier,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
http.Response.Headers["DAV"] = "1, calendar-access";
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
var role = CalendarAccessService.PrimaryRole(user);
|
||||
var calendars = (await access.ForUserAsync(userId, role, ct)).Where(c => CalendarRules.CanRead(c.Access)).ToDictionary(c => c.Calendar.Id);
|
||||
var settings = await settingsProvider.GetAsync(ct);
|
||||
var ctx = new Ctx(http, userId, user.Identity?.Name ?? string.Empty, user.FindFirst(ApiConventions.DisplayNameClaim)?.Value ?? user.Identity?.Name ?? string.Empty,
|
||||
role, calendars, db, settings.TimeZoneId, ct);
|
||||
|
||||
var path = DavPath.Parse(http.Features.Get<IHttpRequestFeature>()?.RawTarget ?? http.Request.Path.Value);
|
||||
return http.Request.Method.ToUpperInvariant() switch
|
||||
{
|
||||
"OPTIONS" => Options(http),
|
||||
"PROPFIND" => await PropFindAsync(ctx, path),
|
||||
"PROPPATCH" => await PropPatchAsync(ctx, path),
|
||||
"REPORT" => await ReportAsync(ctx, path, timeProvider.GetUtcNow()),
|
||||
"GET" or "HEAD" => await GetAsync(ctx, path),
|
||||
"PUT" => await PutAsync(ctx, path, notifier),
|
||||
"DELETE" => await DeleteAsync(ctx, path, notifier),
|
||||
"MKCOL" or "MKCALENDAR" or "MOVE" or "COPY" => Error(StatusCodes.Status403Forbidden, DAV("forbidden"), "Kalender werden im Familienplaner verwaltet, Termine nur per PUT/DELETE geändert."),
|
||||
_ => new DavResult(StatusCodes.Status405MethodNotAllowed, headers: h => h.Allow = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE"),
|
||||
};
|
||||
}
|
||||
|
||||
private static XName DAV(string name) => D + name;
|
||||
|
||||
private static DavResult Error(int status, XName precondition, string? description = null) =>
|
||||
new(status, CalDavXml.Error(precondition, description));
|
||||
|
||||
private static IResult Options(HttpContext http)
|
||||
{
|
||||
http.Response.Headers.Allow = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE";
|
||||
return new DavResult(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// ---------- PROPFIND ----------
|
||||
|
||||
private static async Task<IResult> PropFindAsync(Ctx c, DavPath path)
|
||||
{
|
||||
var depth = c.Http.Request.Headers["Depth"].ToString().Trim().ToLowerInvariant();
|
||||
if (depth == "infinity")
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, DAV("propfind-finite-depth"));
|
||||
}
|
||||
|
||||
var withChildren = depth == "1";
|
||||
var request = DavPropRequest.FromPropfind(TryParse(await ReadBodyAsync(c.Http.Request, c.Ct)));
|
||||
var nodes = new List<Node>();
|
||||
switch (path.Kind)
|
||||
{
|
||||
case DavKind.Root:
|
||||
nodes.Add(RootNode(c));
|
||||
if (withChildren)
|
||||
{
|
||||
nodes.Add(PrincipalsNode());
|
||||
nodes.Add(HomeNode(c));
|
||||
}
|
||||
|
||||
break;
|
||||
case DavKind.PrincipalsRoot:
|
||||
nodes.Add(PrincipalsNode());
|
||||
if (withChildren)
|
||||
{
|
||||
nodes.Add(PrincipalNode(c));
|
||||
}
|
||||
|
||||
break;
|
||||
case DavKind.Principal when path.PrincipalId == c.UserId:
|
||||
nodes.Add(PrincipalNode(c));
|
||||
break;
|
||||
case DavKind.Home:
|
||||
nodes.Add(HomeNode(c));
|
||||
if (withChildren)
|
||||
{
|
||||
var stamps = await StampsAsync(c);
|
||||
nodes.AddRange(c.Calendars.Values.OrderBy(x => x.Calendar.SortOrder).ThenBy(x => x.Calendar.Name).Select(x => CalendarNode(x, stamps[x.Calendar.Id])));
|
||||
}
|
||||
|
||||
break;
|
||||
case DavKind.Calendar when c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar):
|
||||
var state = await LoadStateAsync(c, calendar.Calendar);
|
||||
nodes.Add(CalendarNode(calendar, state.Stamp));
|
||||
if (withChildren)
|
||||
{
|
||||
nodes.AddRange(state.Resources.Select(r => EventNode(c, calendar, r)));
|
||||
}
|
||||
|
||||
break;
|
||||
case DavKind.Event when c.Calendars.TryGetValue(path.CalendarId!.Value, out var eventCalendar):
|
||||
var resource = await FindResourceAsync(c, eventCalendar.Calendar, path.Uid!);
|
||||
if (resource is null)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
nodes.Add(EventNode(c, eventCalendar, resource));
|
||||
break;
|
||||
default:
|
||||
return new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
return Multistatus(nodes.Select(n => Render(n, request)));
|
||||
}
|
||||
|
||||
private static DavResult Multistatus(IEnumerable<XElement> responses, string? syncToken = null) =>
|
||||
new(207, CalDavXml.Multistatus(responses, syncToken));
|
||||
|
||||
private static XElement Render(Node node, DavPropRequest request)
|
||||
{
|
||||
if (request.PropNameOnly)
|
||||
{
|
||||
return CalDavXml.Response(node.Href, node.Props.Keys.Select(n => new XElement(n)), []);
|
||||
}
|
||||
|
||||
if (request.AllProp)
|
||||
{
|
||||
// allprop liefert die „billigen“ Eigenschaften; calendar-data nur auf ausdrückliche Anfrage.
|
||||
return CalDavXml.Response(node.Href, node.Props.Where(p => p.Key != C + "calendar-data").Select(p => p.Value()), []);
|
||||
}
|
||||
|
||||
var found = request.Names.Where(node.Props.ContainsKey).Select(n => node.Props[n]()).ToList();
|
||||
return CalDavXml.Response(node.Href, found, request.Names.Where(n => !node.Props.ContainsKey(n)));
|
||||
}
|
||||
|
||||
private static Func<XElement> Prop(XName name, params object[] content) => () => new XElement(name, content);
|
||||
|
||||
private static XElement Href(string href) => new(D + "href", href);
|
||||
|
||||
private static XElement Privilege(params XName[] names) =>
|
||||
new(D + "current-user-privilege-set", names.Select(n => new XElement(D + "privilege", new XElement(n))));
|
||||
|
||||
private static Dictionary<XName, Func<XElement>> CommonProps(string displayName, params XElement[] resourceType) => new()
|
||||
{
|
||||
[D + "displayname"] = Prop(D + "displayname", displayName),
|
||||
[D + "resourcetype"] = Prop(D + "resourcetype", resourceType),
|
||||
};
|
||||
|
||||
private static Node RootNode(Ctx c)
|
||||
{
|
||||
var props = CommonProps("Familienplaner", new XElement(D + "collection"));
|
||||
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(DavPath.Principal(c.UserId)));
|
||||
props[D + "principal-URL"] = Prop(D + "principal-URL", Href(DavPath.Principal(c.UserId)));
|
||||
props[D + "principal-collection-set"] = Prop(D + "principal-collection-set", Href(DavPath.PrincipalsPath));
|
||||
props[C + "calendar-home-set"] = Prop(C + "calendar-home-set", Href(DavPath.HomePath));
|
||||
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
|
||||
return new Node(DavPath.RootPath, props);
|
||||
}
|
||||
|
||||
private static Node PrincipalsNode() =>
|
||||
new(DavPath.PrincipalsPath, CommonProps("Personen", new XElement(D + "collection")));
|
||||
|
||||
private static Node PrincipalNode(Ctx c)
|
||||
{
|
||||
var href = DavPath.Principal(c.UserId);
|
||||
var props = CommonProps(c.DisplayName, new XElement(D + "collection"), new XElement(D + "principal"));
|
||||
props[D + "principal-URL"] = Prop(D + "principal-URL", Href(href));
|
||||
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(href));
|
||||
props[C + "calendar-home-set"] = Prop(C + "calendar-home-set", Href(DavPath.HomePath));
|
||||
props[C + "calendar-user-address-set"] = Prop(C + "calendar-user-address-set", Href(href));
|
||||
props[D + "principal-collection-set"] = Prop(D + "principal-collection-set", Href(DavPath.PrincipalsPath));
|
||||
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
|
||||
return new Node(href, props);
|
||||
}
|
||||
|
||||
private static Node HomeNode(Ctx c)
|
||||
{
|
||||
var props = CommonProps("Kalender", new XElement(D + "collection"));
|
||||
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(DavPath.Principal(c.UserId)));
|
||||
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
|
||||
return new Node(DavPath.HomePath, props);
|
||||
}
|
||||
|
||||
private static Node CalendarNode(CalendarWithAccess calendar, DateTimeOffset stamp)
|
||||
{
|
||||
var token = CalDavSyncToken.Create(stamp);
|
||||
var color = calendar.Calendar.Color.Length == 7 ? calendar.Calendar.Color + "FF" : calendar.Calendar.Color;
|
||||
var props = CommonProps(calendar.Calendar.Name, new XElement(D + "collection"), new XElement(C + "calendar"));
|
||||
props[C + "supported-calendar-component-set"] = Prop(C + "supported-calendar-component-set", new XElement(C + "comp", new XAttribute("name", "VEVENT")));
|
||||
props[IC + "calendar-color"] = Prop(IC + "calendar-color", color);
|
||||
props[IC + "calendar-order"] = Prop(IC + "calendar-order", calendar.Calendar.SortOrder.ToString(CultureInfo.InvariantCulture));
|
||||
props[CS + "getctag"] = Prop(CS + "getctag", token);
|
||||
props[D + "sync-token"] = Prop(D + "sync-token", token);
|
||||
props[C + "max-resource-size"] = Prop(C + "max-resource-size", CalDavXml.MaxRequestBytes.ToString(CultureInfo.InvariantCulture));
|
||||
props[D + "supported-report-set"] = Prop(
|
||||
D + "supported-report-set",
|
||||
new XElement(D + "supported-report", new XElement(D + "report", new XElement(C + "calendar-query"))),
|
||||
new XElement(D + "supported-report", new XElement(D + "report", new XElement(C + "calendar-multiget"))),
|
||||
new XElement(D + "supported-report", new XElement(D + "report", new XElement(D + "sync-collection"))));
|
||||
props[D + "current-user-privilege-set"] = () => CalendarRules.CanCreate(calendar.Access)
|
||||
? Privilege(D + "read", D + "read-current-user-privilege-set", D + "write", D + "write-content", D + "bind", D + "unbind")
|
||||
: Privilege(D + "read", D + "read-current-user-privilege-set");
|
||||
return new Node(DavPath.Calendar(calendar.Calendar.Id), props);
|
||||
}
|
||||
|
||||
private static Node EventNode(Ctx c, CalendarWithAccess calendar, DavResource resource)
|
||||
{
|
||||
var canModify = CalendarRules.CanModify(calendar.Access, resource.Master.CreatedBy, c.UserId);
|
||||
var props = new Dictionary<XName, Func<XElement>>
|
||||
{
|
||||
[D + "getetag"] = Prop(D + "getetag", resource.ETag),
|
||||
[D + "getcontenttype"] = Prop(D + "getcontenttype", ContentTypeEvent),
|
||||
[D + "getlastmodified"] = Prop(D + "getlastmodified", resource.LastModified.ToString("R", CultureInfo.InvariantCulture)),
|
||||
[D + "resourcetype"] = Prop(D + "resourcetype"),
|
||||
[D + "current-user-privilege-set"] = () => canModify
|
||||
? Privilege(D + "read", D + "read-current-user-privilege-set", D + "write", D + "write-content", D + "unbind")
|
||||
: Privilege(D + "read", D + "read-current-user-privilege-set"),
|
||||
[C + "calendar-data"] = () => new XElement(C + "calendar-data", Serialize(c, resource)),
|
||||
};
|
||||
return new Node(DavPath.Event(resource.CalendarId, resource.Uid), props);
|
||||
}
|
||||
|
||||
private static string Serialize(Ctx c, DavResource resource) =>
|
||||
IcsBuilder.Build(string.Empty, c.TimeZoneId, resource.Events.ToList(), new Dictionary<Guid, string>(), new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true);
|
||||
|
||||
// ---------- PROPPATCH ----------
|
||||
|
||||
/// <summary>Eigenschaften (Name, Farbe …) werden im Familienplaner verwaltet; Clients bekommen je Eigenschaft 403, damit sie nicht hängen.</summary>
|
||||
private static async Task<IResult> PropPatchAsync(Ctx c, DavPath path)
|
||||
{
|
||||
var body = TryParse(await ReadBodyAsync(c.Http.Request, c.Ct));
|
||||
var names = body?.Root?.Elements().SelectMany(e => e.Elements(D + "prop")).SelectMany(p => p.Elements()).Select(e => e.Name).ToList() ?? [];
|
||||
var href = path.Kind switch
|
||||
{
|
||||
DavKind.Calendar when path.CalendarId is { } id && c.Calendars.ContainsKey(id) => DavPath.Calendar(id),
|
||||
DavKind.Principal when path.PrincipalId == c.UserId => DavPath.Principal(c.UserId),
|
||||
_ => null,
|
||||
};
|
||||
return href is null
|
||||
? new DavResult(StatusCodes.Status404NotFound)
|
||||
: Multistatus([PropStatResponse(href, names, StatusCodes.Status403Forbidden)]);
|
||||
}
|
||||
|
||||
// ---------- REPORT ----------
|
||||
|
||||
private static async Task<IResult> ReportAsync(Ctx c, DavPath path, DateTimeOffset now)
|
||||
{
|
||||
var root = TryParse(await ReadBodyAsync(c.Http.Request, c.Ct))?.Root;
|
||||
if (root is null)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (path.Kind != DavKind.Calendar || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
|
||||
{
|
||||
return path.Kind == DavKind.Calendar
|
||||
? new DavResult(StatusCodes.Status404NotFound)
|
||||
: Error(StatusCodes.Status403Forbidden, DAV("supported-report"), "Berichte sind nur für Kalender möglich.");
|
||||
}
|
||||
|
||||
var request = DavPropRequest.FromReport(root);
|
||||
var state = await LoadStateAsync(c, calendar.Calendar);
|
||||
if (root.Name == C + "calendar-query")
|
||||
{
|
||||
var query = CalendarQuery.Parse(root);
|
||||
return Multistatus(state.Resources.Where(r => query.Matches(r, now)).Select(r => Render(EventNode(c, calendar, r), request)));
|
||||
}
|
||||
|
||||
if (root.Name == C + "calendar-multiget")
|
||||
{
|
||||
var responses = new List<XElement>();
|
||||
foreach (var href in root.Elements(D + "href").Select(h => h.Value.Trim()))
|
||||
{
|
||||
var target = DavPath.Parse(href);
|
||||
var resource = target is { Kind: DavKind.Event, CalendarId: { } cid } && cid == calendar.Calendar.Id
|
||||
? state.Resources.FirstOrDefault(r => r.Uid == target.Uid)
|
||||
: null;
|
||||
responses.Add(resource is null ? StatusResponse(href, StatusCodes.Status404NotFound) : Render(EventNode(c, calendar, resource), request));
|
||||
}
|
||||
|
||||
return Multistatus(responses);
|
||||
}
|
||||
|
||||
if (root.Name == D + "sync-collection")
|
||||
{
|
||||
if (!CalDavSyncToken.TryParse(root.Element(D + "sync-token")?.Value, out var since) || since > state.Stamp)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, DAV("valid-sync-token"), "Das Sync-Token ist ungültig. Bitte vollständig neu synchronisieren.");
|
||||
}
|
||||
|
||||
var (changed, removed) = CalDavSyncToken.Delta(state, since);
|
||||
var responses = changed.Select(r => Render(EventNode(c, calendar, r), request))
|
||||
.Concat(removed.Select(t => StatusResponse(DavPath.Event(calendar.Calendar.Id, t.Uid), StatusCodes.Status404NotFound)));
|
||||
return Multistatus(responses, CalDavSyncToken.Create(state.Stamp));
|
||||
}
|
||||
|
||||
return Error(StatusCodes.Status403Forbidden, DAV("supported-report"));
|
||||
}
|
||||
|
||||
// ---------- GET ----------
|
||||
|
||||
private static async Task<IResult> GetAsync(Ctx c, DavPath path)
|
||||
{
|
||||
if (path.Kind != DavKind.Event || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar)
|
||||
|| await FindResourceAsync(c, calendar.Calendar, path.Uid!) is not { } resource)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var response = c.Http.Response;
|
||||
response.Headers.ETag = resource.ETag;
|
||||
response.Headers.LastModified = resource.LastModified.ToString("R", CultureInfo.InvariantCulture);
|
||||
if (CalDavResources.MatchesAny(c.Http.Request.Headers.IfNoneMatch.ToString(), resource.ETag))
|
||||
{
|
||||
return new DavResult(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return new DavResult(StatusCodes.Status200OK, Serialize(c, resource), "text/calendar; charset=utf-8");
|
||||
}
|
||||
|
||||
// ---------- PUT ----------
|
||||
|
||||
private static async Task<IResult> PutAsync(Ctx c, DavPath path, CalendarNotifier notifier)
|
||||
{
|
||||
if (path.Kind != DavKind.Event || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
|
||||
{
|
||||
return path.Kind == DavKind.Event ? new DavResult(StatusCodes.Status404NotFound) : new DavResult(StatusCodes.Status405MethodNotAllowed);
|
||||
}
|
||||
|
||||
var cid = calendar.Calendar.Id;
|
||||
var uid = path.Uid!;
|
||||
var rows = await c.Db.CalendarEvents.Where(e => e.CalendarId == cid && e.Uid == uid).ToListAsync(c.Ct);
|
||||
var existing = CalDavResources.Build(cid, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault();
|
||||
|
||||
// Rechte zuerst: wer nicht schreiben darf, erfährt nichts über Inhalt oder ETag.
|
||||
if (existing is null ? !CalendarRules.CanCreate(calendar.Access) : !CalendarRules.CanModify(calendar.Access, existing.Master.CreatedBy, c.UserId))
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, DAV("need-privileges"), "Keine Berechtigung zum Ändern dieses Termins.");
|
||||
}
|
||||
|
||||
var headers = c.Http.Request.Headers;
|
||||
var ifMatch = headers.IfMatch.ToString();
|
||||
var ifNoneMatch = headers.IfNoneMatch.ToString();
|
||||
if ((ifNoneMatch.Trim() == "*" && existing is not null)
|
||||
|| (!string.IsNullOrWhiteSpace(ifMatch) && (existing is null || !CalDavResources.MatchesAny(ifMatch, existing.ETag)))
|
||||
|| (existing is not null && !string.IsNullOrWhiteSpace(ifNoneMatch) && CalDavResources.MatchesAny(ifNoneMatch, existing.ETag)))
|
||||
{
|
||||
return new DavResult(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
var text = await ReadBodyAsync(c.Http.Request, c.Ct);
|
||||
if (text is null)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "max-resource-size", "Die Datei ist zu groß (höchstens 1 MB).");
|
||||
}
|
||||
|
||||
var rejection = Validate(text, uid, CalendarTime.Zone(c.TimeZoneId), out var master, out var overrides);
|
||||
if (rejection is not null)
|
||||
{
|
||||
return rejection;
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
master!.CalendarId = cid;
|
||||
master.PersonIds = [c.UserId];
|
||||
c.Db.CalendarEvents.Add(master);
|
||||
foreach (var o in overrides)
|
||||
{
|
||||
Attach(o, master);
|
||||
c.Db.CalendarEvents.Add(o);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyUpdate(c.Db, existing, master!, overrides);
|
||||
}
|
||||
|
||||
c.Db.ChangeTracker.DetectChanges();
|
||||
foreach (var entry in c.Db.ChangeTracker.Entries<CalendarEvent>().Where(e => e.State == EntityState.Modified))
|
||||
{
|
||||
entry.Entity.Sequence++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await c.Db.SaveChangesAsync(c.Ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
await notifier.ChangedAsync(cid, c.Ct);
|
||||
|
||||
// Bewusst ohne ETag-Header: Die gespeicherte Fassung ist normalisiert und nicht byte-gleich zum Gesendeten (RFC 4791 §5.3.4);
|
||||
// Clients laden sie dann per GET.
|
||||
return new DavResult(existing is null ? StatusCodes.Status201Created : StatusCodes.Status204NoContent);
|
||||
}
|
||||
|
||||
/// <summary>Prüft den Körper eines PUT; liefert die Fehlerantwort oder (bei Erfolg) <c>null</c> mit Serie und Überschreibungen.</summary>
|
||||
internal static IResult? Validate(string text, string uid, TimeZoneInfo zone, out CalendarEvent? master, out List<CalendarEvent> overrides)
|
||||
{
|
||||
master = null;
|
||||
overrides = [];
|
||||
if (UnsupportedComponent.IsMatch(text))
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "supported-calendar-component", "Der Familienplaner speichert nur Termine (VEVENT), keine Aufgaben oder Journale.");
|
||||
}
|
||||
|
||||
var parsed = IcsImporter.Parse(text, zone);
|
||||
if (parsed.IsInvalid)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-data", "Keine gültige iCalendar-Datei.");
|
||||
}
|
||||
|
||||
if (parsed.Warnings.Count > 0 || parsed.Events.Count == 0)
|
||||
{
|
||||
// Nicht unterstützte Wiederholungen würden sonst stillschweigend zu einem Einzeltermin – das wäre Datenverlust für den Client.
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", string.Join(" ", parsed.Warnings.DefaultIfEmpty("Die Datei enthält keinen Termin.")));
|
||||
}
|
||||
|
||||
if (parsed.Events.Any(e => e.Event.Uid != uid))
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Alle Termine müssen die UID des Dateinamens tragen (<UID>.ics).");
|
||||
}
|
||||
|
||||
var masters = parsed.Events.Where(e => e.RecurrenceId is null).ToList();
|
||||
var changes = parsed.Events.Where(e => e.RecurrenceId is not null).ToList();
|
||||
if (masters.Count != 1 || changes.Select(e => e.RecurrenceId).Distinct().Count() != changes.Count)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Erwartet wird genau ein Haupttermin je UID (plus Änderungen einzelner Vorkommen).");
|
||||
}
|
||||
|
||||
if (changes.Count > 0 && masters[0].Event.RecurrenceRule is null)
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Änderungen einzelner Vorkommen setzen eine Wiederholung voraus.");
|
||||
}
|
||||
|
||||
master = masters[0].Event;
|
||||
overrides = changes.Select(e => { e.Event.RecurrenceId = e.RecurrenceId; return e.Event; }).ToList();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Attach(CalendarEvent o, CalendarEvent master)
|
||||
{
|
||||
o.CalendarId = master.CalendarId;
|
||||
o.SeriesMasterId = master.Id;
|
||||
o.Uid = master.Uid;
|
||||
o.PersonIds = [.. master.PersonIds];
|
||||
o.ForWholeFamily = master.ForWholeFamily;
|
||||
o.RecurrenceRule = null;
|
||||
o.ExceptionDates = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Überträgt den Inhalt auf die vorhandenen Zeilen (Ids, Ersteller, Personen-Tags und „ganze Familie“ bleiben erhalten – Clients kennen sie nicht)
|
||||
/// und gleicht die Überschreibungen über RECURRENCE-ID ab.
|
||||
/// </summary>
|
||||
private static void ApplyUpdate(AppDbContext db, DavResource existing, CalendarEvent incoming, List<CalendarEvent> incomingOverrides)
|
||||
{
|
||||
var master = existing.Master;
|
||||
CopyContent(master, incoming);
|
||||
master.RecurrenceRule = incoming.RecurrenceRule;
|
||||
master.ExceptionDates = incoming.RecurrenceRule is null ? [] : incoming.ExceptionDates;
|
||||
|
||||
var current = existing.Overrides.ToDictionary(o => o.RecurrenceId!.Value);
|
||||
var keep = new HashSet<DateTimeOffset>();
|
||||
foreach (var change in master.RecurrenceRule is null ? [] : incomingOverrides)
|
||||
{
|
||||
var key = change.RecurrenceId!.Value;
|
||||
keep.Add(key);
|
||||
if (current.TryGetValue(key, out var row))
|
||||
{
|
||||
CopyContent(row, change);
|
||||
}
|
||||
else
|
||||
{
|
||||
change.CalendarId = master.CalendarId;
|
||||
Attach(change, master);
|
||||
db.CalendarEvents.Add(change);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, row) in current.Where(kv => !keep.Contains(kv.Key)))
|
||||
{
|
||||
db.CalendarEvents.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyContent(CalendarEvent target, CalendarEvent source)
|
||||
{
|
||||
target.Title = source.Title;
|
||||
target.Location = source.Location;
|
||||
target.Notes = source.Notes;
|
||||
target.IsAllDay = source.IsAllDay;
|
||||
target.StartUtc = source.StartUtc;
|
||||
target.EndUtc = source.EndUtc;
|
||||
target.TimeZoneId = source.TimeZoneId;
|
||||
}
|
||||
|
||||
// ---------- DELETE ----------
|
||||
|
||||
private static async Task<IResult> DeleteAsync(Ctx c, DavPath path, CalendarNotifier notifier)
|
||||
{
|
||||
if (path.Kind != DavKind.Event)
|
||||
{
|
||||
return path.Kind == DavKind.Calendar && c.Calendars.ContainsKey(path.CalendarId!.Value)
|
||||
? Error(StatusCodes.Status403Forbidden, DAV("forbidden"), "Kalender können nur in der Verwaltung des Familienplaners gelöscht werden.")
|
||||
: new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
|
||||
{
|
||||
return new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var cid = calendar.Calendar.Id;
|
||||
var uid = path.Uid!;
|
||||
var rows = await c.Db.CalendarEvents.Where(e => e.CalendarId == cid && e.Uid == uid).ToListAsync(c.Ct);
|
||||
if (CalDavResources.Build(cid, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault() is not { } resource)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!CalendarRules.CanModify(calendar.Access, resource.Master.CreatedBy, c.UserId))
|
||||
{
|
||||
return Error(StatusCodes.Status403Forbidden, DAV("need-privileges"), "Keine Berechtigung zum Löschen dieses Termins.");
|
||||
}
|
||||
|
||||
var ifMatch = c.Http.Request.Headers.IfMatch.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(ifMatch) && !CalDavResources.MatchesAny(ifMatch, resource.ETag))
|
||||
{
|
||||
return new DavResult(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
c.Db.CalendarEvents.RemoveRange(rows);
|
||||
try
|
||||
{
|
||||
await c.Db.SaveChangesAsync(c.Ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new DavResult(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
await notifier.ChangedAsync(cid, c.Ct);
|
||||
return new DavResult(StatusCodes.Status204NoContent);
|
||||
}
|
||||
|
||||
// ---------- Laden ----------
|
||||
|
||||
private static async Task<DavCollectionState> LoadStateAsync(Ctx c, FamilyCalendar calendar)
|
||||
{
|
||||
var rows = await c.Db.CalendarEvents.IgnoreQueryFilters().AsNoTracking().Where(e => e.CalendarId == calendar.Id).ToListAsync(c.Ct);
|
||||
return CalDavResources.Build(calendar.Id, calendar.UpdatedAt, rows);
|
||||
}
|
||||
|
||||
private static async Task<DavResource?> FindResourceAsync(Ctx c, FamilyCalendar calendar, string uid)
|
||||
{
|
||||
var rows = await c.Db.CalendarEvents.AsNoTracking().Where(e => e.CalendarId == calendar.Id && e.Uid == uid).ToListAsync(c.Ct);
|
||||
return CalDavResources.Build(calendar.Id, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Änderungsstand (CTag) aller sichtbaren Kalender in einer Abfrage.</summary>
|
||||
private static async Task<Dictionary<Guid, DateTimeOffset>> StampsAsync(Ctx c)
|
||||
{
|
||||
var ids = c.Calendars.Keys.ToList();
|
||||
var latest = (await c.Db.CalendarEvents.IgnoreQueryFilters().Where(e => ids.Contains(e.CalendarId))
|
||||
.GroupBy(e => e.CalendarId).Select(g => new { Id = g.Key, Max = g.Max(e => e.UpdatedAt) }).ToListAsync(c.Ct))
|
||||
.ToDictionary(x => x.Id, x => x.Max);
|
||||
return ids.ToDictionary(id => id, id => latest.TryGetValue(id, out var max) && max > c.Calendars[id].Calendar.UpdatedAt ? max : c.Calendars[id].Calendar.UpdatedAt);
|
||||
}
|
||||
|
||||
/// <summary>Liest den Körper als Text; <c>null</c>, wenn er größer als 1 MB ist. Leer = leerer Text.</summary>
|
||||
private static async Task<string?> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.ContentLength > CalDavXml.MaxRequestBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await request.Body.ReadAsync(chunk, ct)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > CalDavXml.MaxRequestBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.CalDav;
|
||||
|
||||
/// <summary>Art der Adresse unter <c>/caldav</c>.</summary>
|
||||
public enum DavKind
|
||||
{
|
||||
Unknown,
|
||||
Root,
|
||||
PrincipalsRoot,
|
||||
Principal,
|
||||
Home,
|
||||
Calendar,
|
||||
Event,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adressraum (ADR-0017): <c>/caldav/</c> (Wurzel), <c>/caldav/principals/{userId}/</c>, <c>/caldav/calendars/</c> (calendar-home-set),
|
||||
/// <c>/caldav/calendars/{calendarId}/</c> (ein Kalender) und <c>/caldav/calendars/{calendarId}/{uid}.ics</c> (ein Termin samt Ausnahmen).
|
||||
/// </summary>
|
||||
public sealed record DavPath(DavKind Kind, Guid? PrincipalId = null, Guid? CalendarId = null, string? Uid = null)
|
||||
{
|
||||
public const string RootPath = "/caldav/";
|
||||
public const string PrincipalsPath = "/caldav/principals/";
|
||||
public const string HomePath = "/caldav/calendars/";
|
||||
public const string ResourceExtension = ".ics";
|
||||
|
||||
public static string Principal(Guid userId) => $"{PrincipalsPath}{userId:D}/";
|
||||
|
||||
public static string Calendar(Guid calendarId) => $"{HomePath}{calendarId:D}/";
|
||||
|
||||
public static string Event(Guid calendarId, string uid) => Calendar(calendarId) + EncodeName(uid) + ResourceExtension;
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt einen unverarbeiteten Pfad (noch prozentkodiert, ggf. mit Host oder Query) in einen <see cref="DavPath"/>.
|
||||
/// Jedes Segment wird genau einmal dekodiert.
|
||||
/// </summary>
|
||||
public static DavPath Parse(string? rawPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
{
|
||||
return new DavPath(DavKind.Unknown);
|
||||
}
|
||||
|
||||
var path = rawPath;
|
||||
if (path.Contains("://", StringComparison.Ordinal) && Uri.TryCreate(path, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
path = absolute.AbsolutePath;
|
||||
}
|
||||
|
||||
var queryStart = path.IndexOfAny(['?', '#']);
|
||||
if (queryStart >= 0)
|
||||
{
|
||||
path = path[..queryStart];
|
||||
}
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0 || !string.Equals(segments[0], "caldav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new DavPath(DavKind.Unknown);
|
||||
}
|
||||
|
||||
string Decode(string segment) => Uri.UnescapeDataString(segment);
|
||||
|
||||
switch (segments.Length)
|
||||
{
|
||||
case 1:
|
||||
return new DavPath(DavKind.Root);
|
||||
case 2 when segments[1] == "principals":
|
||||
return new DavPath(DavKind.PrincipalsRoot);
|
||||
case 3 when segments[1] == "principals":
|
||||
return Guid.TryParse(Decode(segments[2]), out var userId) ? new DavPath(DavKind.Principal, PrincipalId: userId) : new DavPath(DavKind.Unknown);
|
||||
case 2 when segments[1] == "calendars":
|
||||
return new DavPath(DavKind.Home);
|
||||
case 3 when segments[1] == "calendars":
|
||||
return Guid.TryParse(Decode(segments[2]), out var calendarId) ? new DavPath(DavKind.Calendar, CalendarId: calendarId) : new DavPath(DavKind.Unknown);
|
||||
case 4 when segments[1] == "calendars" && Guid.TryParse(Decode(segments[2]), out var cid):
|
||||
var name = Decode(segments[3]);
|
||||
return name.EndsWith(ResourceExtension, StringComparison.OrdinalIgnoreCase) && name.Length > ResourceExtension.Length
|
||||
? new DavPath(DavKind.Event, CalendarId: cid, Uid: name[..^ResourceExtension.Length])
|
||||
: new DavPath(DavKind.Unknown);
|
||||
default:
|
||||
return new DavPath(DavKind.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ressourcenname aus der UID: unreservierte Zeichen und <c>@</c> bleiben, alles andere wird UTF-8-prozentkodiert
|
||||
/// (auch <c>/</c> und <c>%</c>, damit die UID ein einzelnes Segment bleibt).
|
||||
/// </summary>
|
||||
public static string EncodeName(string uid)
|
||||
{
|
||||
var builder = new StringBuilder(uid.Length + 8);
|
||||
foreach (var b in Encoding.UTF8.GetBytes(uid))
|
||||
{
|
||||
var c = (char)b;
|
||||
if (b < 0x80 && (char.IsAsciiLetterOrDigit(c) || c is '-' or '.' or '_' or '~' or '@'))
|
||||
{
|
||||
builder.Append(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Termin im Sinne von CalDAV: Serie (oder Einzeltermin) mit allen Überschreibungen unter einer UID.</summary>
|
||||
public sealed record DavResource(
|
||||
Guid CalendarId,
|
||||
string Uid,
|
||||
CalendarEvent Master,
|
||||
IReadOnlyList<CalendarEvent> Overrides,
|
||||
DateTimeOffset LastModified,
|
||||
string ETag)
|
||||
{
|
||||
public IEnumerable<CalendarEvent> Events => Overrides.Prepend(Master);
|
||||
}
|
||||
|
||||
/// <summary>Ein gelöschter Termin (für <c>sync-collection</c>: Antwort 404).</summary>
|
||||
public sealed record DavTombstone(string Uid, DateTimeOffset DeletedAt);
|
||||
|
||||
/// <summary>Zustand eines Kalenders: lebende Ressourcen, Löschungen und der Änderungsstand (CTag / Sync-Token).</summary>
|
||||
public sealed record DavCollectionState(IReadOnlyList<DavResource> Resources, IReadOnlyList<DavTombstone> Tombstones, DateTimeOffset Stamp);
|
||||
|
||||
public static class CalDavResources
|
||||
{
|
||||
/// <summary>
|
||||
/// Gruppiert die Zeilen (auch gelöschte!) eines Kalenders nach UID. Der Änderungsstand eines Termins ist die jüngste Änderung
|
||||
/// aller seiner Zeilen inklusive gelöschter Überschreibungen; der des Kalenders die jüngste Änderung überhaupt.
|
||||
/// </summary>
|
||||
public static DavCollectionState Build(Guid calendarId, DateTimeOffset calendarUpdatedAt, IEnumerable<CalendarEvent> rows)
|
||||
{
|
||||
var all = rows.ToList();
|
||||
var stamp = all.Select(e => e.UpdatedAt).Append(calendarUpdatedAt).Max();
|
||||
|
||||
var resources = new List<DavResource>();
|
||||
var tombstones = new List<DavTombstone>();
|
||||
foreach (var group in all.GroupBy(e => e.Uid, StringComparer.Ordinal))
|
||||
{
|
||||
var masters = group.Where(e => e.SeriesMasterId is null).ToList();
|
||||
var master = masters.FirstOrDefault(e => !e.IsDeleted);
|
||||
var lastModified = group.Max(e => e.UpdatedAt);
|
||||
if (master is null)
|
||||
{
|
||||
if (masters.Count > 0)
|
||||
{
|
||||
tombstones.Add(new DavTombstone(group.Key, lastModified));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var overrides = group.Where(e => !e.IsDeleted && e.SeriesMasterId == master.Id).OrderBy(e => e.RecurrenceId).ToList();
|
||||
resources.Add(new DavResource(calendarId, group.Key, master, overrides, lastModified, ETag(overrides.Prepend(master))));
|
||||
}
|
||||
|
||||
return new DavCollectionState(resources.OrderBy(r => r.Uid, StringComparer.Ordinal).ToList(), tombstones, stamp);
|
||||
}
|
||||
|
||||
/// <summary>Starker ETag (in Anführungszeichen) über Id, RowVersion und Änderungszeit aller Zeilen des Termins.</summary>
|
||||
public static string ETag(IEnumerable<CalendarEvent> rows)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var row in rows.OrderBy(r => r.Id))
|
||||
{
|
||||
builder.Append(row.Id.ToString("N")).Append(':')
|
||||
.Append(Convert.ToHexString(row.RowVersion)).Append(':')
|
||||
.Append(row.UpdatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return "\"" + Convert.ToBase64String(hash)[..27].Replace('+', '-').Replace('/', '_') + "\"";
|
||||
}
|
||||
|
||||
/// <summary>Entspricht ein If-Match/If-None-Match-Wert (Liste, <c>*</c>, schwache Tags) dem ETag?</summary>
|
||||
public static bool MatchesAny(string? headerValue, string etag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var part in headerValue.Split(','))
|
||||
{
|
||||
var tag = part.Trim();
|
||||
if (tag == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tag.StartsWith("W/", StringComparison.Ordinal))
|
||||
{
|
||||
tag = tag[2..];
|
||||
}
|
||||
|
||||
if (string.Equals(tag, etag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sync-Token / CTag (RFC 6578): die jüngste Änderung des Kalenders in Ticks. Zustandslos, daher ohne Tabelle für Tokens.</summary>
|
||||
public static class CalDavSyncToken
|
||||
{
|
||||
private const string Prefix = "urn:familyplanner:sync:";
|
||||
|
||||
public static string Create(DateTimeOffset stamp) => Prefix + stamp.UtcTicks.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Leeres Token = Erstsynchronisation (<c>since = null</c>). Ungültig: <c>false</c>.</summary>
|
||||
public static bool TryParse(string? token, out DateTimeOffset? since)
|
||||
{
|
||||
since = null;
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
token = token.Trim();
|
||||
if (!token.StartsWith(Prefix, StringComparison.Ordinal)
|
||||
|| !long.TryParse(token[Prefix.Length..], NumberStyles.None, CultureInfo.InvariantCulture, out var ticks)
|
||||
|| ticks > DateTimeOffset.MaxValue.UtcTicks)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
since = new DateTimeOffset(ticks, TimeSpan.Zero);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seit dem Token geänderte Ressourcen und Löschungen. Strikt „größer“: ein Gleichstand gilt als bereits gesehen.</summary>
|
||||
public static (IReadOnlyList<DavResource> Changed, IReadOnlyList<DavTombstone> Removed) Delta(DavCollectionState state, DateTimeOffset? since) =>
|
||||
since is null
|
||||
? (state.Resources, [])
|
||||
: (state.Resources.Where(r => r.LastModified > since).ToList(), state.Tombstones.Where(t => t.DeletedAt > since).ToList());
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.CalDav;
|
||||
|
||||
/// <summary>XML-Namen und Hilfen für WebDAV/CalDAV (RFC 4918, 4791, 6578). Nur System.Xml.Linq, ohne zusätzliche Pakete.</summary>
|
||||
public static class CalDavXml
|
||||
{
|
||||
public static readonly XNamespace D = "DAV:";
|
||||
public static readonly XNamespace C = "urn:ietf:params:xml:ns:caldav";
|
||||
public static readonly XNamespace CS = "http://calendarserver.org/ns/";
|
||||
public static readonly XNamespace IC = "http://apple.com/ns/ical/";
|
||||
|
||||
public const int MaxRequestBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Sicheres Einlesen: keine DTDs, keine externen Entitäten, begrenzte Größe. <c>null</c> bei leerem oder ungültigem Text.</summary>
|
||||
public static XDocument? TryParse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null,
|
||||
MaxCharactersInDocument = MaxRequestBytes,
|
||||
};
|
||||
using var reader = XmlReader.Create(new StringReader(text), settings);
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Serialize(XDocument document)
|
||||
{
|
||||
var settings = new XmlWriterSettings { Encoding = new UTF8Encoding(false), Indent = false, OmitXmlDeclaration = false };
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = XmlWriter.Create(stream, settings))
|
||||
{
|
||||
document.Save(writer);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static XElement Root(XName name, params object?[] content) => new(
|
||||
name,
|
||||
new XAttribute(XNamespace.Xmlns + "D", D),
|
||||
new XAttribute(XNamespace.Xmlns + "C", C),
|
||||
new XAttribute(XNamespace.Xmlns + "CS", CS),
|
||||
new XAttribute(XNamespace.Xmlns + "IC", IC),
|
||||
content);
|
||||
|
||||
public static string Multistatus(IEnumerable<XElement> responses, string? syncToken = null) =>
|
||||
Serialize(new XDocument(new XDeclaration("1.0", "utf-8", null), Root(D + "multistatus", responses, syncToken is null ? null : new XElement(D + "sync-token", syncToken))));
|
||||
|
||||
/// <summary>Fehlerantwort mit Vorbedingung, z. B. <c>CALDAV:valid-calendar-data</c> oder <c>DAV:valid-sync-token</c>.</summary>
|
||||
public static string Error(XName precondition, string? description = null) =>
|
||||
Serialize(new XDocument(new XDeclaration("1.0", "utf-8", null),
|
||||
Root(D + "error", new XElement(precondition), description is null ? null : new XElement(D + "responsedescription", description))));
|
||||
|
||||
private static string StatusLine(int code) => code switch
|
||||
{
|
||||
200 => "HTTP/1.1 200 OK",
|
||||
403 => "HTTP/1.1 403 Forbidden",
|
||||
404 => "HTTP/1.1 404 Not Found",
|
||||
_ => $"HTTP/1.1 {code.ToString(CultureInfo.InvariantCulture)}",
|
||||
};
|
||||
|
||||
/// <summary>Eine <c>response</c> mit den gefundenen Eigenschaften (200) und den unbekannten (404).</summary>
|
||||
public static XElement Response(string href, IEnumerable<XElement> found, IEnumerable<XName> notFound)
|
||||
{
|
||||
var response = new XElement(D + "response", new XElement(D + "href", href));
|
||||
var foundList = found.ToList();
|
||||
if (foundList.Count > 0)
|
||||
{
|
||||
response.Add(PropStat(foundList, 200));
|
||||
}
|
||||
|
||||
var missing = notFound.Select(n => new XElement(n)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
response.Add(PropStat(missing, 404));
|
||||
}
|
||||
|
||||
if (foundList.Count == 0 && missing.Count == 0)
|
||||
{
|
||||
response.Add(PropStat([], 200));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Eine <c>response</c> nur mit Status (gelöschte Ressource bei sync-collection, nicht gefundener href bei multiget).</summary>
|
||||
public static XElement StatusResponse(string href, int status) =>
|
||||
new(D + "response", new XElement(D + "href", href), new XElement(D + "status", StatusLine(status)));
|
||||
|
||||
public static XElement PropStatResponse(string href, IEnumerable<XName> names, int status) =>
|
||||
new(D + "response", new XElement(D + "href", href), PropStat(names.Select(n => new XElement(n)).ToList(), status));
|
||||
|
||||
private static XElement PropStat(List<XElement> props, int status) =>
|
||||
new(D + "propstat", new XElement(D + "prop", props), new XElement(D + "status", StatusLine(status)));
|
||||
|
||||
public static string DateTimeUtc(DateTimeOffset value) =>
|
||||
value.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
|
||||
|
||||
public static bool TryParseDateTimeUtc(string? text, out DateTimeOffset value)
|
||||
{
|
||||
value = default;
|
||||
if (!DateTime.TryParseExact(text, "yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = new DateTimeOffset(DateTime.SpecifyKind(parsed, DateTimeKind.Utc));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Welche Eigenschaften will der Client (PROPFIND, REPORT)?</summary>
|
||||
public sealed record DavPropRequest(bool AllProp, bool PropNameOnly, IReadOnlyList<XName> Names)
|
||||
{
|
||||
public static readonly DavPropRequest All = new(true, false, []);
|
||||
|
||||
public bool Wants(XName name) => AllProp || PropNameOnly || Names.Contains(name);
|
||||
|
||||
/// <summary>PROPFIND-Körper: leer oder <c>allprop</c> = alles; <c>propname</c>; sonst die Liste unter <c>prop</c>.</summary>
|
||||
public static DavPropRequest FromPropfind(XDocument? body)
|
||||
{
|
||||
if (body?.Root is not { } root)
|
||||
{
|
||||
return All;
|
||||
}
|
||||
|
||||
if (root.Element(CalDavXml.D + "propname") is not null)
|
||||
{
|
||||
return new DavPropRequest(false, true, []);
|
||||
}
|
||||
|
||||
return root.Element(CalDavXml.D + "prop") is { } prop ? new DavPropRequest(false, false, prop.Elements().Select(e => e.Name).ToList()) : All;
|
||||
}
|
||||
|
||||
/// <summary>Eigenschaften unter <c>DAV:prop</c> eines REPORT-Körpers.</summary>
|
||||
public static DavPropRequest FromReport(XElement report) =>
|
||||
report.Element(CalDavXml.D + "prop") is { } prop ? new DavPropRequest(false, false, prop.Elements().Select(e => e.Name).ToList()) : All;
|
||||
}
|
||||
|
||||
/// <summary>Filter eines <c>calendar-query</c> (RFC 4791 §9.7), soweit unterstützt: VEVENT, time-range, prop-filter auf UID/SUMMARY/LOCATION/DESCRIPTION.</summary>
|
||||
public sealed record CalendarQuery(bool MatchesEvents, DateTimeOffset? RangeStart, DateTimeOffset? RangeEnd, IReadOnlyList<PropFilter> PropFilters)
|
||||
{
|
||||
public static readonly CalendarQuery MatchAll = new(true, null, null, []);
|
||||
|
||||
public static CalendarQuery Parse(XElement query)
|
||||
{
|
||||
var filter = query.Element(CalDavXml.C + "filter");
|
||||
var calendarFilter = filter?.Elements(CalDavXml.C + "comp-filter").FirstOrDefault(f => NameIs(f, "VCALENDAR"));
|
||||
if (calendarFilter is null)
|
||||
{
|
||||
return MatchAll;
|
||||
}
|
||||
|
||||
var children = calendarFilter.Elements(CalDavXml.C + "comp-filter").ToList();
|
||||
if (children.Count == 0)
|
||||
{
|
||||
return MatchAll;
|
||||
}
|
||||
|
||||
var eventFilter = children.FirstOrDefault(f => NameIs(f, "VEVENT"));
|
||||
if (eventFilter is null)
|
||||
{
|
||||
return new CalendarQuery(false, null, null, []); // VTODO, VJOURNAL, VFREEBUSY: gibt es hier nicht
|
||||
}
|
||||
|
||||
DateTimeOffset? start = null;
|
||||
DateTimeOffset? end = null;
|
||||
if (eventFilter.Element(CalDavXml.C + "time-range") is { } range)
|
||||
{
|
||||
start = CalDavXml.TryParseDateTimeUtc((string?)range.Attribute("start"), out var s) ? s : null;
|
||||
end = CalDavXml.TryParseDateTimeUtc((string?)range.Attribute("end"), out var e) ? e : null;
|
||||
}
|
||||
|
||||
var props = eventFilter.Elements(CalDavXml.C + "prop-filter").Select(PropFilter.Parse).ToList();
|
||||
return new CalendarQuery(true, start, end, props);
|
||||
}
|
||||
|
||||
/// <summary>Trifft der Termin den Filter? Bei Zeitbereich werden Serien expandiert (Überschreibungen und EXDATE beachtet).</summary>
|
||||
public bool Matches(DavResource resource, DateTimeOffset now)
|
||||
{
|
||||
if (!MatchesEvents)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PropFilters.Any(f => !f.Matches(resource.Master)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RangeStart is null && RangeEnd is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var from = RangeStart ?? DateTimeOffset.UnixEpoch.AddYears(-100);
|
||||
var to = RangeEnd ?? now.AddYears(10);
|
||||
return CalendarTime.Expand(resource.Events, from, to).Count > 0;
|
||||
}
|
||||
|
||||
private static bool NameIs(XElement element, string name) =>
|
||||
string.Equals((string?)element.Attribute("name"), name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary><c>prop-filter</c>: Eigenschaft ist (nicht) definiert oder enthält/gleicht einem Text (ohne Beachtung der Groß-/Kleinschreibung).</summary>
|
||||
public sealed record PropFilter(string Name, bool IsNotDefined, string? Text, string MatchType, bool Negate)
|
||||
{
|
||||
public static PropFilter Parse(XElement element)
|
||||
{
|
||||
var textMatch = element.Element(CalDavXml.C + "text-match");
|
||||
return new PropFilter(
|
||||
((string?)element.Attribute("name") ?? string.Empty).ToUpperInvariant(),
|
||||
element.Element(CalDavXml.C + "is-not-defined") is not null,
|
||||
textMatch?.Value,
|
||||
((string?)textMatch?.Attribute("match-type") ?? "contains").ToLowerInvariant(),
|
||||
string.Equals((string?)textMatch?.Attribute("negate-condition"), "yes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public bool Matches(CalendarEvent master)
|
||||
{
|
||||
var value = Name switch
|
||||
{
|
||||
"UID" => master.Uid,
|
||||
"SUMMARY" => master.Title,
|
||||
"LOCATION" => master.Location,
|
||||
"DESCRIPTION" => master.Notes,
|
||||
_ => null, // nicht gespeicherte Eigenschaft: nie „definiert“
|
||||
};
|
||||
|
||||
if (IsNotDefined)
|
||||
{
|
||||
return string.IsNullOrEmpty(value);
|
||||
}
|
||||
|
||||
if (Text is null)
|
||||
{
|
||||
return !string.IsNullOrEmpty(value); // nur „Eigenschaft vorhanden“
|
||||
}
|
||||
|
||||
var hit = value is not null && MatchType switch
|
||||
{
|
||||
"equals" => string.Equals(value, Text, StringComparison.OrdinalIgnoreCase),
|
||||
"starts-with" => value.StartsWith(Text, StringComparison.OrdinalIgnoreCase),
|
||||
"ends-with" => value.EndsWith(Text, StringComparison.OrdinalIgnoreCase),
|
||||
_ => value.Contains(Text, StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
return hit != Negate;
|
||||
}
|
||||
}
|
||||
@@ -35,19 +35,23 @@ public static class IcsBuilder
|
||||
IReadOnlyDictionary<Guid, string> calendarNames,
|
||||
IReadOnlyDictionary<Guid, string> personNames,
|
||||
IReadOnlyList<IcsTask> tasks,
|
||||
IcsTaskMode taskMode)
|
||||
IcsTaskMode taskMode,
|
||||
bool resourceMode = false)
|
||||
{
|
||||
var zone = CalendarTime.Zone(timeZoneId);
|
||||
var calendar = new IcalCalendar { ProductId = ProductId };
|
||||
calendar.AddProperty("X-WR-CALNAME", feedName);
|
||||
calendar.AddProperty("X-WR-TIMEZONE", timeZoneId);
|
||||
calendar.AddProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT15M");
|
||||
calendar.AddProperty("X-PUBLISHED-TTL", "PT15M");
|
||||
if (!resourceMode)
|
||||
{
|
||||
calendar.AddProperty("X-WR-CALNAME", feedName);
|
||||
calendar.AddProperty("X-WR-TIMEZONE", timeZoneId);
|
||||
calendar.AddProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT15M");
|
||||
calendar.AddProperty("X-PUBLISHED-TTL", "PT15M");
|
||||
}
|
||||
|
||||
// Serien vor ihren Überschreibungen (manche Clients erwarten diese Reihenfolge).
|
||||
foreach (var e in events.OrderBy(e => e.Uid, StringComparer.Ordinal).ThenBy(e => e.IsOverride).ThenBy(e => e.RecurrenceId))
|
||||
{
|
||||
calendar.Events.Add(ToIcal(e, timeZoneId, zone, calendarNames, personNames));
|
||||
calendar.Events.Add(ToIcal(e, timeZoneId, zone, calendarNames, personNames, resourceMode));
|
||||
}
|
||||
|
||||
foreach (var task in tasks.OrderBy(t => t.DueDate).ThenBy(t => t.Id))
|
||||
@@ -74,14 +78,17 @@ public static class IcsBuilder
|
||||
public static string TaskUid(Guid taskId) => $"task-{taskId:N}@familyplanner";
|
||||
|
||||
private static IcalEvent ToIcal(
|
||||
DomainEvent e, string tzId, TimeZoneInfo zone, IReadOnlyDictionary<Guid, string> calendarNames, IReadOnlyDictionary<Guid, string> personNames)
|
||||
DomainEvent e, string tzId, TimeZoneInfo zone, IReadOnlyDictionary<Guid, string> calendarNames, IReadOnlyDictionary<Guid, string> personNames,
|
||||
bool resourceMode)
|
||||
{
|
||||
var ical = new IcalEvent
|
||||
{
|
||||
Uid = e.Uid,
|
||||
Summary = e.Title,
|
||||
Location = e.Location,
|
||||
Description = Description(e, personNames),
|
||||
|
||||
// CalDAV-Ressource: nur die Notiz. Die „Für: …“-Zeile würde sonst bei jedem Speichern durch einen Client in die Notiz wandern.
|
||||
Description = resourceMode ? e.Notes : Description(e, personNames),
|
||||
Sequence = e.Sequence,
|
||||
DtStamp = Utc(e.UpdatedAt),
|
||||
LastModified = Utc(e.UpdatedAt),
|
||||
@@ -90,7 +97,7 @@ public static class IcsBuilder
|
||||
DtEnd = Local(e.EndUtc, e.IsAllDay, tzId, zone),
|
||||
};
|
||||
|
||||
if (calendarNames.TryGetValue(e.CalendarId, out var calendarName))
|
||||
if (!resourceMode && calendarNames.TryGetValue(e.CalendarId, out var calendarName))
|
||||
{
|
||||
ical.Categories = [calendarName];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Features.CalDav;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Infrastructure.Identity;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
using FamilyPlanner.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -29,6 +31,12 @@ internal static class AuthSetup
|
||||
services.AddAuthentication(IdentityConstants.ApplicationScheme)
|
||||
.AddIdentityCookies();
|
||||
|
||||
// CalDAV (ADR-0017): eigenes Basic-Schema mit App-Passwörtern, nur über die Policy "CalDav" aktiv – nie Standard, nie für /api.
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, CalDavBasicHandler>(CalDavBasicHandler.SchemeName, null);
|
||||
services.AddOptions<CalDavOptions>().BindConfiguration(CalDavOptions.SectionName);
|
||||
services.AddSingleton<CalDavFailureThrottle>();
|
||||
|
||||
services.AddFamilyPlannerIdentityCore()
|
||||
.AddSignInManager<AppSignInManager>()
|
||||
.AddClaimsPrincipalFactory<AppClaimsPrincipalFactory>()
|
||||
@@ -90,6 +98,13 @@ internal static class AuthSetup
|
||||
builder.AddPolicy(policy, p => p.Combine(PasswordChangedPolicy()).RequireRole(roles));
|
||||
}
|
||||
|
||||
// CalDAV: nur das App-Passwort-Schema; Kalenderrechte je Kalender prüft der Slice bei jeder Anfrage.
|
||||
builder.AddPolicy(CalDavEndpoints.PolicyName, new AuthorizationPolicyBuilder(CalDavBasicHandler.SchemeName)
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(ActiveAccountRequirement.Instance)
|
||||
.AddRequirements(new PermissionRequirement(Permission.CalendarRead))
|
||||
.Build());
|
||||
|
||||
foreach (var permission in PermissionMatrix.All)
|
||||
{
|
||||
builder.AddPolicy(Policies.For(permission), p => p
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace FamilyPlanner.Api.Infrastructure;
|
||||
internal static class HostingSetup
|
||||
{
|
||||
public const string LoginRateLimitPolicy = "login";
|
||||
public const string CalDavRateLimitPolicy = "caldav";
|
||||
public const string GifSearchRateLimitPolicy = "gif-search";
|
||||
public const string GifPreviewRateLimitPolicy = "gif-preview";
|
||||
|
||||
@@ -28,6 +29,17 @@ internal static class HostingSetup
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
// CalDAV (ADR-0017): großzügig je IP (iOS stellt bei der Erstsynchronisation viele Anfragen); Fehlversuche begrenzt CalDavFailureThrottle.
|
||||
options.AddPolicy(CalDavRateLimitPolicy, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = builder.Configuration.GetValue("RateLimiting:CalDavPermitLimit", 600),
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
|
||||
// GIF-Suche und -Vorschauen (ADR-0022): je angemeldeter Person begrenzt, damit niemand den Giphy-Schlüssel der Familie leerfragt.
|
||||
options.AddPolicy(GifSearchRateLimitPolicy, context => PerUser(context, 20));
|
||||
options.AddPolicy(GifPreviewRateLimitPolicy, context => PerUser(context, 200));
|
||||
|
||||
@@ -2,6 +2,7 @@ using FamilyPlanner.Api.Features.Admin;
|
||||
using FamilyPlanner.Api.Features.Ai;
|
||||
using FamilyPlanner.Api.Features.Auth;
|
||||
using FamilyPlanner.Api.Features.Calendar;
|
||||
using FamilyPlanner.Api.Features.CalDav;
|
||||
using FamilyPlanner.Api.Features.Chat;
|
||||
using FamilyPlanner.Api.Features.Dashboard;
|
||||
using FamilyPlanner.Api.Features.Learning;
|
||||
@@ -101,6 +102,8 @@ app.MapPushEndpoints();
|
||||
app.MapCalendarEndpoints();
|
||||
app.MapCalendarAdminEndpoints();
|
||||
app.MapFeedEndpoints();
|
||||
app.MapCalDavEndpoints();
|
||||
app.MapCalDavCredentialEndpoints();
|
||||
app.MapDashboardEndpoints();
|
||||
app.MapChatEndpoints();
|
||||
app.MapAiEndpoints();
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"CalDav": {
|
||||
"RequireHttps": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FluentValidation;
|
||||
|
||||
namespace FamilyPlanner.Shared.Calendar;
|
||||
|
||||
/// <summary>Ein App-Passwort für Kalender-Apps (ohne das Geheimnis).</summary>
|
||||
public sealed record CalDavCredentialDto(
|
||||
Guid Id,
|
||||
string Label,
|
||||
string SecretPrefix,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastUsedAt,
|
||||
string? LastUsedClient,
|
||||
DateTimeOffset? RevokedAt);
|
||||
|
||||
public sealed record CreateCalDavCredentialRequest(string Label);
|
||||
|
||||
/// <summary>Neu erzeugtes App-Passwort – das Geheimnis wird nur in dieser Antwort geliefert.</summary>
|
||||
public sealed record CalDavCredentialSecretDto(CalDavCredentialDto Credential, string Secret);
|
||||
|
||||
/// <summary>Verbindungsdaten für Kalender-Apps: Server-Adresse und Benutzername des Angemeldeten.</summary>
|
||||
public sealed record CalDavInfoDto(string ServerUrl, string UserName, bool IsSecure);
|
||||
|
||||
public sealed class CreateCalDavCredentialRequestValidator : AbstractValidator<CreateCalDavCredentialRequest>
|
||||
{
|
||||
public CreateCalDavCredentialRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Label)
|
||||
.NotEmpty().WithMessage("Bitte eine Bezeichnung eingeben, z. B. „iPhone von Anna“.")
|
||||
.MaximumLength(CalDavCredential.LabelMaxLength).WithMessage($"Die Bezeichnung darf höchstens {CalDavCredential.LabelMaxLength} Zeichen haben.");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Yarp.ReverseProxy.Forwarder;
|
||||
namespace FamilyPlanner.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-for-Frontend: leitet <c>/api</c>, <c>/hubs</c> und <c>/ics</c> same-origin an die Api weiter (ADR-0002).
|
||||
/// Backend-for-Frontend: leitet <c>/api</c>, <c>/hubs</c>, <c>/ics</c> und <c>/caldav</c> (inkl. <c>/.well-known/caldav</c>) same-origin an die Api weiter (ADR-0002).
|
||||
/// Die Adresse der Api kommt über Aspire Service Discovery (<c>services__api__…</c>).
|
||||
/// </summary>
|
||||
internal static class BffSetup
|
||||
@@ -19,6 +19,9 @@ internal static class BffSetup
|
||||
Route("api", "/api/{**catch-all}"),
|
||||
Route("hubs", "/hubs/{**catch-all}"),
|
||||
Route("ics", "/ics/{**catch-all}"),
|
||||
Route("caldav", "/caldav/{**catch-all}"),
|
||||
Route("caldav-root", "/caldav"),
|
||||
Route("caldav-wellknown", "/.well-known/caldav"),
|
||||
];
|
||||
|
||||
ClusterConfig[] clusters =
|
||||
|
||||
Reference in new issue
Block a user