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
264 lines
7.4 KiB
Go
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
|
|
}
|