feat: CalDAV server with app password authentication

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 22:03:55 +02:00
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));
+3
View File
@@ -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.");
}
}
+4 -1
View File
@@ -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 =