Files
paliad/internal/services/caldav_client.go
m b56ef660df feat(termine): Phase F — Termine (appointments) + CalDAV sync
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.

Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
  Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
  Termine follow AkteService.GetByID. Every Akte-attached mutation appends
  an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
  caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
  UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
  Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
  to start with malformed key; unset key leaves CalDAV disabled and all
  /api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
  paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
  /api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.

Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
  TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
  test-connection button, status card, sync log table, delete button that
  purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
  add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.

Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password

t-paliad-010
2026-04-17 11:59:49 +02:00

264 lines
7.4 KiB
Go

package services
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Tiny CalDAV HTTP client — only the verbs Paliad needs:
// - PUT (create / replace event)
// - GET (fetch event by path)
// - DELETE (remove event)
// - PROPFIND with depth=1 (list calendar contents + ETags)
// - PROPFIND on the calendar URL (used by the "test connection" button)
//
// We don't use REPORT because PROPFIND-depth-1 returns enough for our needs
// (href + getetag + calendar-data via a follow-up GET) and is supported
// by every server Paliad has been tested against (Nextcloud, Radicale,
// Baikal, mailcow SOGo).
type calDAVClient struct {
baseURL string // server root or calendar URL
username string
password string
hc *http.Client
}
func newCalDAVClient(baseURL, username, password string) *calDAVClient {
return &calDAVClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
hc: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *calDAVClient) do(ctx context.Context, method, path, body, contentType string) (*http.Response, error) {
u := c.absURL(path)
req, err := http.NewRequestWithContext(ctx, method, u, strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return c.hc.Do(req)
}
// absURL resolves path against baseURL. If path is already absolute (starts
// with http:// or https://), it's used as-is.
func (c *calDAVClient) absURL(path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if path == "" {
return c.baseURL
}
if strings.HasPrefix(path, "/") {
// Same scheme + host as base.
base, err := url.Parse(c.baseURL)
if err == nil && base.Scheme != "" && base.Host != "" {
return base.Scheme + "://" + base.Host + path
}
}
return c.baseURL + "/" + strings.TrimLeft(path, "/")
}
// PutEvent uploads body to <calendarPath>/<uid>.ics. Returns the new ETag.
func (c *calDAVClient) PutEvent(ctx context.Context, calendarPath, uid, body string) (string, error) {
path := joinPath(calendarPath, uid+".ics")
resp, err := c.do(ctx, "PUT", path, body, "text/calendar; charset=utf-8")
if err != nil {
return "", fmt.Errorf("PUT: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("PUT %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
}
etag := resp.Header.Get("ETag")
if etag == "" {
// Some servers don't return ETag on PUT — re-PROPFIND to fetch it.
entries, ferr := c.PropfindCalendar(ctx, calendarPath)
if ferr == nil {
for _, e := range entries {
if strings.HasSuffix(e.Href, uid+".ics") {
etag = e.ETag
break
}
}
}
}
return etag, nil
}
// DeleteEvent removes a previously-PUT event. 404 is treated as success.
func (c *calDAVClient) DeleteEvent(ctx context.Context, calendarPath, uid string) error {
path := joinPath(calendarPath, uid+".ics")
resp, err := c.do(ctx, "DELETE", path, "", "")
if err != nil {
return fmt.Errorf("DELETE: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
return nil
}
if resp.StatusCode >= 300 {
return fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, resp.Status)
}
return nil
}
// GetEvent fetches the raw iCalendar body of one event.
func (c *calDAVClient) GetEvent(ctx context.Context, href string) (string, error) {
resp, err := c.do(ctx, "GET", href, "", "")
if err != nil {
return "", fmt.Errorf("GET: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: %d", href, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
return string(body), nil
}
// CalDAVEntry is one row from a depth-1 PROPFIND on a calendar.
type CalDAVEntry struct {
Href string
ETag string
}
// PropfindCalendar lists all event hrefs + ETags inside calendarPath.
func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string) ([]CalDAVEntry, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(calendarPath), strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("PROPFIND: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
}
return parseMultiStatus(resp.Body)
}
// PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by
// the "Test connection" button to verify auth + URL without storing creds.
func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:resourcetype/>
<d:displayname/>
</d:prop>
</d:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(path), strings.NewReader(body))
if err != nil {
return err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", "0")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("PROPFIND: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 && resp.StatusCode != 200 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("PROPFIND %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
}
return nil
}
// joinPath cleans up double slashes between calendar path and uid.
func joinPath(base, name string) string {
base = strings.TrimRight(base, "/")
name = strings.TrimLeft(name, "/")
if base == "" {
return "/" + name
}
return base + "/" + name
}
// --- Multistatus parsing ---
type msResponse struct {
XMLName xml.Name `xml:"DAV: response"`
Href string `xml:"DAV: href"`
Propstat []propStat `xml:"DAV: propstat"`
}
type propStat struct {
XMLName xml.Name `xml:"DAV: propstat"`
Status string `xml:"DAV: status"`
Prop struct {
ETag string `xml:"DAV: getetag"`
ResourceType struct {
Collection *struct{} `xml:"DAV: collection"`
} `xml:"DAV: resourcetype"`
} `xml:"DAV: prop"`
}
type multiStatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []msResponse `xml:"DAV: response"`
}
func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) {
var ms multiStatus
dec := xml.NewDecoder(r)
if err := dec.Decode(&ms); err != nil {
return nil, fmt.Errorf("decode multistatus: %w", err)
}
out := []CalDAVEntry{}
for _, resp := range ms.Responses {
var etag string
isCollection := false
for _, ps := range resp.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
etag = strings.Trim(ps.Prop.ETag, `"`)
if ps.Prop.ResourceType.Collection != nil {
isCollection = true
}
}
if isCollection {
continue // skip the calendar itself
}
if !strings.HasSuffix(strings.ToLower(resp.Href), ".ics") {
continue
}
out = append(out, CalDAVEntry{Href: resp.Href, ETag: etag})
}
return out, nil
}