This commit is contained in:
Lennart J. Kurzweg (Nx2)
2026-03-22 18:48:45 +01:00
parent 41e36a4545
commit 1d99749f72
12 changed files with 567 additions and 216 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.direnv .direnv
server.log server.log
mem.go
nxcaldav

View File

@@ -1,2 +1,4 @@
.direnv .direnv
server.log server.log
mem.go
nxcaldav

View File

@@ -8,6 +8,8 @@
- name: required - name: required
- password: cleartext or bcrypt hash - password: cleartext or bcrypt hash
- password_cmd: shell command - password_cmd: shell command
# password_cmd: "echo secretpassword" # Command (output will be hashed in DB)
# password: "$2y$12$LU.8xNK6m98hEJ5oRnBsDuMamfIjXoWTW0eMIJ6yGdLoP3nJAHWH6"
# SQL # SQL
## delte user ## delte user

View File

@@ -1,29 +1,66 @@
server: server:
bind_address: "0.0.0.0:8080" bind_address: "0.0.0.0:8080"
public_url: "http://localhost:8080" public_url: "http://localhost:8080"
redaction_text: "Busy (Private)" redaction_text: "[REDACED]"
default_class: "CONFIDENTIAL"
database: database:
url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
users: users:
- name: "alice" - name: "daniel"
password: "password123" # Cleartext (will be hashed in DB) password: "123"
- name: "bob" groups:
password_cmd: "echo secretpassword" # Command (output will be hashed in DB) - family
- name: "charlie" - klosterberg
password: "$2y$12$LU.8xNK6m98hEJ5oRnBsDuMamfIjXoWTW0eMIJ6yGdLoP3nJAHWH6" # Example dummy hash - parent
- name: "diane"
password: "123"
groups:
- family
- klosterberg
- parent
- name: "georg"
password: "123"
groups:
- family
- kids
- name: "lennart"
password: "123"
groups:
- family
- kids
- name: "tessa"
password: "123"
groups:
- family
- kids
- klosterberg
- name: "testuser"
password: "123"
- name: "shared"
password: "123"
calendars: calendars:
- id: "Alice" - id: "school"
owner: "alice" owner: "lennart"
- id: "sport"
owner: "lennart"
- id: "family"
owner: "shared"
access: access:
- user: "bob" - groups: "family"
mode: "read-only"
- id: "team_project"
owner: "alice"
access:
- user: "bob"
mode: "read-write" mode: "read-write"
- user: "charlie" - id: "tessas-inbox"
owner: "tessa"
access:
- group: "parent"
mode: "read-write"
aggregates:
- id: "lennart"
owner: "lennart"
sources: ["school", "sport"]
access:
- group: "family"
mode: "read-only" mode: "read-only"

View File

@@ -1,6 +1,7 @@
package backend package backend
import ( import (
"slices"
"bytes" "bytes"
"context" "context"
"errors" "errors"
@@ -8,6 +9,7 @@ import (
"log" "log"
"net/http" "net/http"
"os/exec" "os/exec"
"path"
"strings" "strings"
"github.com/emersion/go-ical" "github.com/emersion/go-ical"
@@ -19,11 +21,21 @@ import (
"nxcaldav/internal/config" "nxcaldav/internal/config"
) )
// DBBackend implements the caldav.Backend interface using a PostgreSQL database.
// It manages users, calendars, and their events/tasks with built-in support for
// access control, privacy redaction, and aggregate (virtual) calendars.
type DBBackend struct { type DBBackend struct {
pool *pgxpool.Pool pool *pgxpool.Pool // Connection pool to PostgreSQL
redactionText string redactionText string // Text used to hide confidential event details (e.g. "[REDACED]")
defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL")
aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions
userAggs map[string][]string // In-memory map of user -> list of aggregate paths they can see
} }
// NewDBBackend creates a new database-backed CalDAV provider.
// It is called once during main.go startup.
// It initializes the database connection, ensures the tables exist (Schema),
// and synchronizes the config.yaml data into the database tables.
func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) { func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
pool, err := pgxpool.New(ctx, cfg.Database.URL) pool, err := pgxpool.New(ctx, cfg.Database.URL)
if err != nil { if err != nil {
@@ -33,12 +45,17 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
b := &DBBackend{ b := &DBBackend{
pool: pool, pool: pool,
redactionText: cfg.Server.Redaction, redactionText: cfg.Server.Redaction,
defaultClass: cfg.Server.DefaultClass,
aggregates: make(map[string]*config.Aggregate),
userAggs: make(map[string][]string),
} }
// Step 1: Ensure database tables are ready
if err := b.initSchema(ctx); err != nil { if err := b.initSchema(ctx); err != nil {
return nil, err return nil, err
} }
// Step 2: Sync users/calendars from YAML to DB
if err := b.syncConfig(ctx, cfg); err != nil { if err := b.syncConfig(ctx, cfg); err != nil {
return nil, err return nil, err
} }
@@ -46,6 +63,9 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
return b, nil return b, nil
} }
// initSchema runs the DDL queries to create the necessary tables if they don't exist.
// We use ON DELETE CASCADE extensively so that deleting a user or calendar
// automatically wipes all related events and access rules in the DB.
func (b *DBBackend) initSchema(ctx context.Context) error { func (b *DBBackend) initSchema(ctx context.Context) error {
queries := []string{ queries := []string{
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
@@ -84,6 +104,10 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
return nil return nil
} }
// resolvePassword prepares a password for storage.
// It is called by syncConfig for every user.
// 1. If PasswordCmd is set, it runs the bash command to get the password.
// 2. If the password is not already a bcrypt hash, it hashes it.
func (b *DBBackend) resolvePassword(u config.User) (string, error) { func (b *DBBackend) resolvePassword(u config.User) (string, error) {
var raw string var raw string
if u.PasswordCmd != "" { if u.PasswordCmd != "" {
@@ -98,12 +122,11 @@ func (b *DBBackend) resolvePassword(u config.User) (string, error) {
} }
// If it already looks like a bcrypt hash, return as is. // If it already looks like a bcrypt hash, return as is.
// bcrypt hashes usually start with $2a$ or $2b$ or $2y$.
if strings.HasPrefix(raw, "$2") { if strings.HasPrefix(raw, "$2") {
return raw, nil return raw, nil
} }
// Otherwise, hash it. // Hash the raw password before DB entry
hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost)
if err != nil { if err != nil {
return "", err return "", err
@@ -111,6 +134,12 @@ func (b *DBBackend) resolvePassword(u config.User) (string, error) {
return string(hash), nil return string(hash), nil
} }
// syncConfig reconciles the YAML configuration with the PostgreSQL state.
// It is called once at server startup.
// 1. Inserts/Updates all users and their hashed passwords.
// 2. Inserts/Updates all calendars and their access/group rules.
// 3. Builds the in-memory Aggregate maps for fast lookup during requests.
// 4. Performs an 'Orphan Check' to warn the user about DB entries not in config.
func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
tx, err := b.pool.Begin(ctx) tx, err := b.pool.Begin(ctx)
if err != nil { if err != nil {
@@ -120,10 +149,17 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
configUserNames := make(map[string]bool) configUserNames := make(map[string]bool)
configCalendarPaths := make(map[string]bool) configCalendarPaths := make(map[string]bool)
groupMembers := make(map[string][]string)
// Sync Users b.aggregates = make(map[string]*config.Aggregate)
b.userAggs = make(map[string][]string)
// --- Phase 1: User Sync ---
for _, u := range cfg.Users { for _, u := range cfg.Users {
configUserNames[u.Name] = true configUserNames[u.Name] = true
for _, g := range u.Groups {
groupMembers[g] = append(groupMembers[g], u.Name)
}
hashed, err := b.resolvePassword(u) hashed, err := b.resolvePassword(u)
if err != nil { if err != nil {
return err return err
@@ -138,7 +174,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
} }
} }
// Sync Calendars and Access // --- Phase 2: Calendar & Access Sync ---
for _, c := range cfg.Calendars { for _, c := range cfg.Calendars {
path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
configCalendarPaths[path] = true configCalendarPaths[path] = true
@@ -159,86 +195,118 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
return err return err
} }
// Re-build access rules for this calendar
_, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID) _, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID)
if err != nil { if err != nil {
return err return err
} }
calendarAccessModes := make(map[string]string)
for _, a := range c.Access { for _, a := range c.Access {
var tUsers []string
if a.User != "" {
tUsers = append(tUsers, a.User)
}
if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...)
}
if a.Groups != "" {
tUsers = append(tUsers, groupMembers[a.Groups]...)
}
for _, u := range tUsers {
calendarAccessModes[u] = a.Mode
}
}
for uName, mode := range calendarAccessModes {
var userID int var userID int
err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", a.User).Scan(&userID) err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", uName).Scan(&userID)
if err != nil { if err != nil {
return fmt.Errorf("access user %s not found: %v", a.User, err) return fmt.Errorf("access user %s not found: %v", uName, err)
} }
_, err = tx.Exec(ctx, "INSERT INTO calendar_access (calendar_id, user_id, mode) VALUES ($1, $2, $3)", _, err = tx.Exec(ctx, "INSERT INTO calendar_access (calendar_id, user_id, mode) VALUES ($1, $2, $3)",
calID, userID, a.Mode) calID, userID, mode)
if err != nil { if err != nil {
return err return err
} }
} }
} }
// Orphaned User Check // --- Phase 3: Aggregate Setup ---
// Aggregates are virtual, so we only track them in memory for routing.
for _, agg := range cfg.Aggregates {
p_ := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, agg.ID)
if configCalendarPaths[p_] {
return fmt.Errorf("aggregate %s collides with real calendar path", p_)
}
aggCopy := agg
b.aggregates[p_] = &aggCopy
aggAccess := make(map[string]bool)
aggAccess[agg.Owner] = true
for _, a := range agg.Access {
var tUsers []string
if a.User != "" {
tUsers = append(tUsers, a.User)
}
if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...)
}
if a.Groups != "" {
tUsers = append(tUsers, groupMembers[a.Groups]...)
}
for _, u := range tUsers {
aggAccess[u] = true
}
}
for uName := range aggAccess {
b.userAggs[uName] = append(b.userAggs[uName], p_)
}
}
// --- Phase 4: Orphan Checks ---
// Logs a warning if the DB contains users/calendars no longer in the YAML.
userRows, err := tx.Query(ctx, "SELECT name FROM users") userRows, err := tx.Query(ctx, "SELECT name FROM users")
if err != nil { if err == nil {
return err for userRows.Next() {
} var name string
var dbUsers []string if err := userRows.Scan(&name); err == nil && !configUserNames[name] {
for userRows.Next() { log.Printf("WARNING: Orphaned user found in database: %s", name)
var name string }
if err := userRows.Scan(&name); err != nil {
return err
} }
dbUsers = append(dbUsers, name) userRows.Close()
} }
userRows.Close()
for _, name := range dbUsers {
if !configUserNames[name] {
log.Printf("WARNING: Orphaned user found in database: %s (Not in config.yaml)", name)
}
}
// Orphaned Calendar Check
calRows, err := tx.Query(ctx, "SELECT path FROM calendars") calRows, err := tx.Query(ctx, "SELECT path FROM calendars")
if err != nil { if err == nil {
return err for calRows.Next() {
} var p string
var dbPaths []string if err := calRows.Scan(&p); err == nil && !configCalendarPaths[p] {
for calRows.Next() { log.Printf("WARNING: Orphaned calendar found in database: %s", p)
var p string }
if err := calRows.Scan(&p); err != nil {
return err
}
dbPaths = append(dbPaths, p)
}
calRows.Close()
for _, p := range dbPaths {
if !configCalendarPaths[p] {
log.Printf("WARNING: Orphaned calendar found in database: %s (Not in config.yaml)", p)
} }
calRows.Close()
} }
return tx.Commit(ctx) return tx.Commit(ctx)
} }
// VerifyUser checks Basic Auth credentials against the database.
// It is called by the auth middleware in main.go for every HTTP request.
func (b *DBBackend) VerifyUser(ctx context.Context, username, password string) (bool, error) { func (b *DBBackend) VerifyUser(ctx context.Context, username, password string) (bool, error) {
var hash string var hash string
err := b.pool.QueryRow(ctx, "SELECT password FROM users WHERE name = $1", username).Scan(&hash) err := b.pool.QueryRow(ctx, "SELECT password FROM users WHERE name = $1", username).Scan(&hash)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { return false, nil // User not found
return false, nil
}
return false, err
} }
// Securely compare bcrypt hash with the provided plain-text password
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil, nil return err == nil, nil
} }
// UserPrincipalBackend implementation // --- UserPrincipalBackend Implementation ---
// CurrentUserPrincipal returns the principal path injected into the context during authentication.
func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
principal, ok := ctx.Value("principal").(string) principal, ok := ctx.Value("principal").(string)
if !ok { if !ok {
@@ -247,6 +315,7 @@ func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return principal, nil return principal, nil
} }
// getUsername is a helper to extract the user name from a principal path (e.g. "/alice/").
func (b *DBBackend) getUsername(ctx context.Context) (string, error) { func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
principal, err := b.CurrentUserPrincipal(ctx) principal, err := b.CurrentUserPrincipal(ctx)
if err != nil { if err != nil {
@@ -255,6 +324,11 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
return strings.Trim(principal, "/"), nil return strings.Trim(principal, "/"), nil
} }
// --- Internal Security & Privacy Helpers ---
// checkAccess verifies if the current user has the required permission for a real calendar.
// It returns the internal database ID of the calendar if access is granted.
// It is called before any read (PROPFIND, GET, REPORT) or write (PUT, DELETE) operation.
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, error) { func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
@@ -279,10 +353,12 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
return 0, err return 0, err
} }
// Owners always have full access
if ownerName == username { if ownerName == username {
return calID, nil return calID, nil
} }
// Check the calendar_access table for specific permissions
var mode string var mode string
err = b.pool.QueryRow(ctx, ` err = b.pool.QueryRow(ctx, `
SELECT mode FROM calendar_access SELECT mode FROM calendar_access
@@ -295,6 +371,7 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
return 0, err return 0, err
} }
// Enforce Read-Only mode
if requiredMode == "write" && mode != "read-write" { if requiredMode == "write" && mode != "read-write" {
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
} }
@@ -302,18 +379,20 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
return calID, nil return calID, nil
} }
func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, original *ical.Calendar) (*ical.Calendar, error) { // filterCalendar is the privacy enforcement engine of the server.
// It is called every time a calendar object is retrieved from the database
// for a user who is NOT the owner.
// It applies the following rules based on the 'CLASS' property of an event/task:
// 1. PRIVATE: Removes the object entirely.
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
// 3. PUBLIC: Passes the object through unchanged.
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, original *ical.Calendar) (*ical.Calendar, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var ownerName string // Owners see everything unredacted
err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calendarID).Scan(&ownerName)
if err != nil {
return nil, err
}
if username == ownerName { if username == ownerName {
return original, nil return original, nil
} }
@@ -327,15 +406,19 @@ func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, original
continue continue
} }
class := "PUBLIC" class := b.defaultClass;
log.Printf("class: %s",class)
if prop := child.Props.Get("CLASS"); prop != nil { if prop := child.Props.Get("CLASS"); prop != nil {
class = strings.ToUpper(prop.Value) class = strings.ToUpper(prop.Value)
log.Printf("class: %s",class)
} }
switch class { switch class {
case "PRIVATE": case "PRIVATE":
// Don't include private items in the response
continue continue
case "CONFIDENTIAL": case "CONFIDENTIAL":
// Strip all details except time/meta and set a generic summary
redacted := ical.NewComponent(child.Name) redacted := ical.NewComponent(child.Name)
propsToKeep := []string{ propsToKeep := []string{
"UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS", "UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS",
@@ -348,20 +431,23 @@ func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, original
} }
redacted.Props.SetText("SUMMARY", b.redactionText) redacted.Props.SetText("SUMMARY", b.redactionText)
filtered.Children = append(filtered.Children, redacted) filtered.Children = append(filtered.Children, redacted)
default: case "PUBLIC":
log.Printf("pub: %s", child.Name);
filtered.Children = append(filtered.Children, child) filtered.Children = append(filtered.Children, child)
} }
} }
if len(filtered.Children) == 0 { if len(filtered.Children) == 0 {
return nil, nil return nil, nil // Entire file is hidden
} }
return filtered, nil return filtered, nil
} }
// CalDAV Backend implementation // --- CalDAV Backend Implementation ---
// CalendarHomeSetPath returns the root path for a user's calendars (e.g., "/alice/calendars/").
// Called during CalDAV discovery.
func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
@@ -370,12 +456,15 @@ func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
return fmt.Sprintf("/%s/calendars/", username), nil return fmt.Sprintf("/%s/calendars/", username), nil
} }
// ListCalendars returns all calendars (real and virtual) the user has access to.
// Called during PROPFIND on the user's HomeSetPath.
func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Find real calendars (Owned or Shared)
rows, err := b.pool.Query(ctx, ` rows, err := b.pool.Query(ctx, `
SELECT path, name FROM calendars SELECT path, name FROM calendars
WHERE owner_id = (SELECT id FROM users WHERE name = $1) WHERE owner_id = (SELECT id FROM users WHERE name = $1)
@@ -395,16 +484,39 @@ func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error
cal.SupportedComponentSet = []string{"VEVENT", "VTODO"} cal.SupportedComponentSet = []string{"VEVENT", "VTODO"}
res = append(res, cal) res = append(res, cal)
} }
// Add virtual aggregate calendars
for _, p := range b.userAggs[username] {
agg := b.aggregates[p]
res = append(res, caldav.Calendar{
Path: p,
Name: agg.ID,
SupportedComponentSet: []string{"VEVENT", "VTODO"},
})
}
return res, nil return res, nil
} }
func (b *DBBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { // GetCalendar retrieves metadata for a specific calendar.
if !strings.HasSuffix(path, "/") { // Called when a client wants to verify a calendar's settings.
path += "/" func (b *DBBackend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
if !strings.HasSuffix(p, "/") {
p += "/"
} }
// Check if this is an Aggregate route
if agg, ok := b.aggregates[p]; ok {
return &caldav.Calendar{
Path: p,
Name: agg.ID,
SupportedComponentSet: []string{"VEVENT", "VTODO"},
}, nil
}
// Normal database lookup
var cal caldav.Calendar var cal caldav.Calendar
err := b.pool.QueryRow(ctx, "SELECT path, name FROM calendars WHERE path = $1", path).Scan(&cal.Path, &cal.Name) err := b.pool.QueryRow(ctx, "SELECT path, name FROM calendars WHERE path = $1", p).Scan(&cal.Path, &cal.Name)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
@@ -415,18 +527,41 @@ func (b *DBBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calen
return &cal, nil return &cal, nil
} }
// CreateCalendar is disabled because we manage calendars via config.yaml.
func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config")) return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config"))
} }
func (b *DBBackend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { // ListCalendarObjects returns the list of all events/tasks in a calendar.
calID, err := b.checkAccess(ctx, path, "read") // Called during initial sync (PROPFIND Depth:1) or full-calendar reports.
func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
if !strings.HasSuffix(p, "/") {
p += "/"
}
username, _ := b.getUsername(ctx)
log.Printf("[ListCalendarObjects] user=%s path=%s", username, p)
// Route to virtual aggregate logic if needed
if agg, ok := b.aggregates[p]; ok {
// Verify Diane/User can actually see this aggregate
hasAccess := slices.Contains(b.userAggs[username], p)
if !hasAccess {
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied to aggregate"))
}
return b.listAggregateObjects(ctx, p, agg)
}
// Normal calendar logic
calID, err := b.checkAccess(ctx, p, "read")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !strings.HasSuffix(path, "/") { var ownerName string
path += "/" err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calID).Scan(&ownerName)
if err != nil {
return nil, err
} }
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID) rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
@@ -445,14 +580,12 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, path string, req *c
calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil { if err != nil {
return nil, err continue
} }
filteredData, err := b.filterCalendar(ctx, calID, calData) // Apply privacy filters (Private/Confidential)
if err != nil { filteredData, err := b.filterCalendar(ctx, ownerName, calData)
return nil, err if err != nil || filteredData == nil {
}
if filteredData == nil {
continue continue
} }
obj.Data = filteredData obj.Data = filteredData
@@ -461,21 +594,157 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, path string, req *c
return res, nil return res, nil
} }
func (b *DBBackend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { // listAggregateObjects merges multiple real calendars into one virtual view.
lastSlash := strings.LastIndex(path, "/") // It rewrites paths so the client thinks items are in the virtual directory.
if lastSlash == -1 { func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) var res []caldav.CalendarObject
}
parentPath := path[:lastSlash+1] for _, sourceID := range agg.Sources {
sourcePath := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int
var ownerName string
err := b.pool.QueryRow(ctx, `
SELECT c.id, u.name FROM calendars c
JOIN users u ON c.owner_id = u.id
WHERE c.path = $1`, sourcePath).Scan(&calID, &ownerName)
if err != nil {
continue
}
calID, err := b.checkAccess(ctx, parentPath, "read") rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
if err != nil {
continue
}
for rows.Next() {
var obj caldav.CalendarObject
var dataStr string
if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
rows.Close()
return nil, err
}
calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil {
continue
}
// Apply Privacy rules for the aggregate viewer
filtered, err := b.filterCalendar(ctx, ownerName, calData)
if err != nil || filtered == nil {
continue
}
// Prepend source name so Diane knows this is a "[school]" event
for _, child := range filtered.Children {
if child.Name == "VEVENT" || child.Name == "VTODO" {
summary := child.Props.Get("SUMMARY")
val := ""
if summary != nil {
val = summary.Value
}
child.Props.SetText("SUMMARY", fmt.Sprintf("[%s] %s", sourceID, val))
}
}
// Virtualize the path so Thunderbird doesn't reject the file
fileName := path.Base(obj.Path)
obj.Path = aggPath + sourceID + "__" + fileName
obj.Data = filtered
res = append(res, obj)
}
rows.Close()
}
return res, nil
}
// GetCalendarObject retrieves a single event or task file.
// Called during GET, multiget REPORT, or when clicking an event in the client.
func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx)
log.Printf("[GetCalendarObject] user=%s path=%s", username, p)
dirPath := path.Dir(p) + "/"
fileName := path.Base(p)
// Step 1: Check if this is a request for a virtual item in an aggregate
if agg, ok := b.aggregates[dirPath]; ok {
hasAccess := slices.Contains(b.userAggs[username], dirPath)
if !hasAccess {
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied to aggregate"))
}
// Parse the virtual path back to real source
parts := strings.SplitN(fileName, "__", 2)
if len(parts) < 2 {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("invalid aggregate item path"))
}
sourceID := parts[0]
realFileName := parts[1]
sourcePath := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int
var ownerName string
err := b.pool.QueryRow(ctx, "SELECT c.id, u.name FROM calendars c JOIN users u ON c.owner_id = u.id WHERE c.path = $1", sourcePath).Scan(&calID, &ownerName)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("source calendar not found"))
}
var obj caldav.CalendarObject
var dataStr string
realObjPath := sourcePath + realFileName
err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, realObjPath).Scan(&obj.Path, &dataStr, &obj.ETag)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item not found in source"))
}
calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil {
return nil, err
}
filteredData, err := b.filterCalendar(ctx, ownerName, calData)
if err != nil || filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
}
// Re-apply labels for the virtual view
for _, child := range filteredData.Children {
if child.Name == "VEVENT" || child.Name == "VTODO" {
summary := child.Props.Get("SUMMARY")
val := ""
if summary != nil {
val = summary.Value
}
child.Props.SetText("SUMMARY", fmt.Sprintf("[%s] %s", sourceID, val))
}
}
obj.Data = filteredData
obj.Path = p
return &obj, nil
}
// Step 2: Handle normal real calendar objects
if !strings.HasSuffix(dirPath, "/") {
dirPath += "/"
}
calID, err := b.checkAccess(ctx, dirPath, "read")
if err != nil {
return nil, err
}
var ownerName string
err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calID).Scan(&ownerName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var obj caldav.CalendarObject var obj caldav.CalendarObject
var dataStr string var dataStr string
err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, path).Scan(&obj.Path, &dataStr, &obj.ETag) err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, p).Scan(&obj.Path, &dataStr, &obj.ETag)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
@@ -488,25 +757,26 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, path string, req *cal
return nil, err return nil, err
} }
filteredData, err := b.filterCalendar(ctx, calID, calData) // Apply privacy filtering
if err != nil { filteredData, err := b.filterCalendar(ctx, ownerName, calData)
return nil, err if err != nil || filteredData == nil {
}
if filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
} }
obj.Data = filteredData obj.Data = filteredData
return &obj, nil return &obj, nil
} }
func (b *DBBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { // PutCalendarObject saves or updates an event/task.
lastSlash := strings.LastIndex(path, "/") // Called during PUT requests when a user saves a change in their calendar app.
if lastSlash == -1 { func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) dirPath := path.Dir(p) + "/"
// Aggregates are read-only
if _, ok := b.aggregates[dirPath]; ok {
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
} }
parentPath := path[:lastSlash+1]
calID, err := b.checkAccess(ctx, parentPath, "write") calID, err := b.checkAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -518,34 +788,36 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, path string, calendar
dataStr := buf.String() dataStr := buf.String()
etag := fmt.Sprintf(`"%d"`, len(calendar.Events())) etag := fmt.Sprintf(`"%d"`, len(calendar.Events()))
// Use Upsert logic to handle both creation and updates
_, err = b.pool.Exec(ctx, ` _, err = b.pool.Exec(ctx, `
INSERT INTO calendar_objects (calendar_id, path, data, etag) VALUES ($1, $2, $3, $4) INSERT INTO calendar_objects (calendar_id, path, data, etag) VALUES ($1, $2, $3, $4)
ON CONFLICT (calendar_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`, ON CONFLICT (calendar_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
calID, path, dataStr, etag) calID, p, dataStr, etag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &caldav.CalendarObject{ return &caldav.CalendarObject{
Path: path, Path: p,
Data: calendar, Data: calendar,
ETag: etag, ETag: etag,
}, nil }, nil
} }
func (b *DBBackend) DeleteCalendarObject(ctx context.Context, path string) error { // DeleteCalendarObject removes an event or task.
lastSlash := strings.LastIndex(path, "/") // Called during DELETE requests.
if lastSlash == -1 { func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) dirPath := path.Dir(p) + "/"
if _, ok := b.aggregates[dirPath]; ok {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
} }
parentPath := path[:lastSlash+1]
calID, err := b.checkAccess(ctx, parentPath, "write") calID, err := b.checkAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return err return err
} }
commandTag, err := b.pool.Exec(ctx, "DELETE FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, path) commandTag, err := b.pool.Exec(ctx, "DELETE FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, p)
if err != nil { if err != nil {
return err return err
} }
@@ -555,6 +827,11 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, path string) error
return nil return nil
} }
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { // QueryCalendarObjects filters items based on a CalDAV query (e.g. time range).
return b.ListCalendarObjects(ctx, path, nil) // Currently, it just lists all objects and lets the client filter, but
// we use it to enforce privacy rules for the initial report.
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx)
log.Printf("[QueryCalendarObjects] user=%s path=%s", username, path_)
return b.ListCalendarObjects(ctx, path_, nil)
} }

View File

@@ -32,6 +32,13 @@ func NewMemBackend(cfg *config.Config) *MemBackend {
calendarAccess: make(map[string]map[string]string), calendarAccess: make(map[string]map[string]string),
} }
groupMembers := make(map[string][]string)
for _, u := range cfg.Users {
for _, g := range u.Groups {
groupMembers[g] = append(groupMembers[g], u.Name)
}
}
for _, c := range cfg.Calendars { for _, c := range cfg.Calendars {
path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
cal := &caldav.Calendar{ cal := &caldav.Calendar{
@@ -45,8 +52,22 @@ func NewMemBackend(cfg *config.Config) *MemBackend {
accessMap := make(map[string]string) accessMap := make(map[string]string)
for _, a := range c.Access { for _, a := range c.Access {
accessMap[a.User] = a.Mode var tUsers []string
b.userCalendars[a.User] = append(b.userCalendars[a.User], path) if a.User != "" {
tUsers = append(tUsers, a.User)
}
if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...)
}
if a.Groups != "" {
tUsers = append(tUsers, groupMembers[a.Groups]...)
}
for _, u := range tUsers {
if _, exists := accessMap[u]; !exists {
b.userCalendars[u] = append(b.userCalendars[u], path)
}
accessMap[u] = a.Mode
}
} }
b.calendarAccess[path] = accessMap b.calendarAccess[path] = accessMap
} }
@@ -121,7 +142,7 @@ func (b *MemBackend) filterCalendar(ctx context.Context, calendarPath string, or
continue continue
} }
class := "PUBLIC" class := "CONFIDENTIAL"
if prop := child.Props.Get("CLASS"); prop != nil { if prop := child.Props.Get("CLASS"); prop != nil {
class = strings.ToUpper(prop.Value) class = strings.ToUpper(prop.Value)
} }

View File

@@ -2,13 +2,16 @@ package config
import ( import (
"os" "os"
"slices"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Access struct { type Access struct {
User string `yaml:"user"` User string `yaml:"user,omitempty"`
Mode string `yaml:"mode"` // "read-only" or "read-write" Group string `yaml:"group,omitempty"`
Groups string `yaml:"groups,omitempty"`
Mode string `yaml:"mode"` // "read-only" or "read-write"
} }
type Calendar struct { type Calendar struct {
@@ -17,10 +20,18 @@ type Calendar struct {
Access []Access `yaml:"access,omitempty"` Access []Access `yaml:"access,omitempty"`
} }
type Aggregate struct {
ID string `yaml:"id"`
Owner string `yaml:"owner"`
Sources []string `yaml:"sources"` // Calendar IDs
Access []Access `yaml:"access,omitempty"`
}
type User struct { type User struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Password string `yaml:"password"` Password string `yaml:"password"`
PasswordCmd string `yaml:"password_cmd"` PasswordCmd string `yaml:"password_cmd"`
Groups []string `yaml:"groups,omitempty"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -28,16 +39,18 @@ type DatabaseConfig struct {
} }
type ServerConfig struct { type ServerConfig struct {
BindAddress string `yaml:"bind_address"` BindAddress string `yaml:"bind_address"`
PublicURL string `yaml:"public_url"` PublicURL string `yaml:"public_url"`
Redaction string `yaml:"redaction_text"` Redaction string `yaml:"redaction_text"`
DefaultClass string `yaml:"default_class"`
} }
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
Users []User `yaml:"users"` Users []User `yaml:"users"`
Calendars []Calendar `yaml:"calendars"` Calendars []Calendar `yaml:"calendars"`
Aggregates []Aggregate `yaml:"aggregates"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@@ -53,10 +66,17 @@ func Load(path string) (*Config, error) {
return nil, err return nil, err
} }
cfg.checkConfig()
cfg.setDefaults() cfg.setDefaults()
return &cfg, nil return &cfg, nil
} }
func (c *Config) checkConfig() {
if !(slices.Contains([]string{"PUBLIC", "PRIVATE", "CONFIDENTIAL"}, c.Server.DefaultClass)) {
panic("Invaldi Config, default_class")
}
}
func (c *Config) setDefaults() { func (c *Config) setDefaults() {
if c.Server.BindAddress == "" { if c.Server.BindAddress == "" {
c.Server.BindAddress = ":8080" c.Server.BindAddress = ":8080"
@@ -64,6 +84,9 @@ func (c *Config) setDefaults() {
if c.Server.Redaction == "" { if c.Server.Redaction == "" {
c.Server.Redaction = "Busy" c.Server.Redaction = "Busy"
} }
if c.Server.DefaultClass == "" {
c.Server.DefaultClass = "CONFIDENTIAL"
}
if c.Database.URL == "" { if c.Database.URL == "" {
c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
} }

View File

@@ -34,7 +34,7 @@ func main() {
return return
} }
// Verify user against database (bcrypt) // Verify via Database (bcrypt)
valid, err := be.VerifyUser(r.Context(), user, password) valid, err := be.VerifyUser(r.Context(), user, password)
if err != nil { if err != nil {
log.Printf("auth error for %s: %v", user, err) log.Printf("auth error for %s: %v", user, err)
@@ -43,11 +43,12 @@ func main() {
} }
if !valid { if !valid {
log.Printf("auth failed for %s", user)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
log.Printf("%s %s %s", user, r.Method, r.URL.Path) log.Printf("%s %s (user: %s)", r.Method, r.URL.Path, user)
principalPath := fmt.Sprintf("/%s/", user) principalPath := fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
@@ -63,8 +64,8 @@ func main() {
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress) fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress)
server := &http.Server{ server := &http.Server{
Addr: cfg.Server.BindAddress, Addr: cfg.Server.BindAddress,
ReadTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second,
} }
if err := server.ListenAndServe(); err != nil { if err := server.ListenAndServe(); err != nil {
log.Fatalf("server failed: %v", err) log.Fatalf("server failed: %v", err)

BIN
nxcaldav

Binary file not shown.

View File

@@ -1,2 +0,0 @@
2026/03/21 00:44:12 WARNING: Orphaned user found in database: charlie (Not in config.yaml)
Starting CalDAV server with Postgres on :8080...

97
test.py
View File

@@ -1,49 +1,8 @@
import os import os
import yaml import yaml
import subprocess
from caldav import DAVClient from caldav import DAVClient
from ics import Calendar, Todo from ics import Calendar
from datetime import datetime, timedelta from datetime import datetime
def get_password(user_cfg):
if 'password_cmd' in user_cfg:
return subprocess.check_output(user_cfg['password_cmd'], shell=True).decode().strip()
return user_cfg.get('password')
def add_event(calendar, summary, classification, start_hour):
now = datetime.now()
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
dtstart = (now.replace(hour=start_hour, minute=0, second=0)).strftime("%Y%m%dT%H%M%SZ")
dtend = (now.replace(hour=start_hour+1, minute=0, second=0)).strftime("%Y%m%dT%H%M%SZ")
print(f"Adding {classification} event: {summary} at {start_hour}:00")
calendar.add_event(f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//CalDAV Client//EN
BEGIN:VEVENT
UID:uid-{summary.lower().replace(' ', '-')}-{classification.lower()}
DTSTAMP:{dtstamp}
DTSTART:{dtstart}
DTEND:{dtend}
SUMMARY:{summary}
CLASS:{classification}
END:VEVENT
END:VCALENDAR""")
def add_todo(calendar, summary, classification):
now = datetime.now()
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
print(f"Adding {classification} todo: {summary}")
calendar.save_todo(f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//CalDAV Client//EN
BEGIN:VTODO
UID:todo-{summary.lower().replace(' ', '-')}-{classification.lower()}
DTSTAMP:{dtstamp}
SUMMARY:{summary}
CLASS:{classification}
END:VTODO
END:VCALENDAR""")
if __name__ == "__main__": if __name__ == "__main__":
with open("config.yaml") as f: with open("config.yaml") as f:
@@ -51,36 +10,26 @@ if __name__ == "__main__":
url = "http://localhost:8080/" url = "http://localhost:8080/"
alice_cfg = next(u for u in config['users'] if u['name'] == 'alice') # Diane checks the aggregate 'lennart' calendar
alice_pw = get_password(alice_cfg) diane_client = DAVClient(url, username="diane", password="123")
alice_client = DAVClient(url, username=alice_cfg['name'], password=alice_pw) d_principal = diane_client.principal()
alice_principal = alice_client.principal()
alice_personal = next(c for c in alice_principal.calendars() if "personal" in c.url.path)
print("\n--- Alice creating items for TODAY ---")
add_event(alice_personal, "Public Dinner", "PUBLIC", 18)
add_event(alice_personal, "Confidential Meeting", "CONFIDENTIAL", 14)
add_todo(alice_personal, "Secret Task", "PRIVATE")
add_todo(alice_personal, "Sensitive Project", "CONFIDENTIAL")
bob_cfg = next(u for u in config['users'] if u['name'] == 'bob')
bob_pw = get_password(bob_cfg)
bob_client = DAVClient(url, username=bob_cfg['name'], password=bob_pw)
bob_principal = bob_client.principal()
print("\n--- Bob viewing Alice's calendar ---") print("\n--- Diane viewing Aggregate 'lennart' calendar ---")
bob_alice_personal = next(c for c in bob_principal.calendars() if "/alice/calendars/personal/" in c.url.path) agg_path = "/lennart/calendars/lennart/"
lennart_agg = next(c for c in d_principal.calendars() if agg_path in c.url.path)
print("Checking Events...")
events = bob_alice_personal.events() events = lennart_agg.events()
print(f"Events found in aggregate: {len(events)}")
for e in events: for e in events:
c = Calendar(e.data) print(f" - Path: {e.url.path}")
for ev in c.events: if not e.url.path.startswith(agg_path):
print(f" - Event: '{ev.name}' (UID: {ev.uid})") print(f" ERROR: Path {e.url.path} is NOT under the aggregate {agg_path}!")
print("Checking Todos...") # Test individual GET (GetCalendarObject)
todos = bob_alice_personal.todos() try:
for t in todos: data = e.data
c = Calendar(t.data) c = Calendar(data)
for td in c.todos: for ev in c.events:
print(f" - Todo: '{td.name}' (UID: {td.uid})") print(f" Fetched: {ev.name} (UID: {ev.uid})")
except Exception as err:
print(f" ERROR fetching individual item: {err}")

39
verify_group.py Normal file
View File

@@ -0,0 +1,39 @@
import psycopg2
import sys
def check_db():
try:
conn = psycopg2.connect("postgres://nxcaldav@localhost:5432/nxcaldav")
cur = conn.cursor()
print("--- Users ---")
cur.execute("SELECT id, name FROM users")
users = cur.fetchall()
for u in users:
print(u)
print("\n--- Calendars ---")
cur.execute("SELECT id, path, owner_id FROM calendars")
cals = cur.fetchall()
for c in cals:
print(c)
print("\n--- Calendar Access (Diane) ---")
cur.execute("""
SELECT c.path, ca.mode
FROM calendar_access ca
JOIN calendars c ON ca.calendar_id = c.id
JOIN users u ON ca.user_id = u.id
WHERE u.name = 'diane'
""")
access = cur.fetchall()
for a in access:
print(a)
cur.close()
conn.close()
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
check_db()