diff --git a/.gitignore b/.gitignore index 8f4317c..6aee0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .direnv server.log +mem.go +nxcaldav diff --git a/.ignore b/.ignore index 8f4317c..6aee0f3 100644 --- a/.ignore +++ b/.ignore @@ -1,2 +1,4 @@ .direnv server.log +mem.go +nxcaldav diff --git a/README.md b/README.md index da505a6..0301fc1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ - name: required - password: cleartext or bcrypt hash - password_cmd: shell command + # password_cmd: "echo secretpassword" # Command (output will be hashed in DB) + # password: "$2y$12$LU.8xNK6m98hEJ5oRnBsDuMamfIjXoWTW0eMIJ6yGdLoP3nJAHWH6" # SQL ## delte user diff --git a/config.yaml b/config.yaml index 997ed3b..e617602 100644 --- a/config.yaml +++ b/config.yaml @@ -1,29 +1,66 @@ server: bind_address: "0.0.0.0:8080" public_url: "http://localhost:8080" - redaction_text: "Busy (Private)" + redaction_text: "[REDACED]" + default_class: "CONFIDENTIAL" database: url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" users: - - name: "alice" - password: "password123" # Cleartext (will be hashed in DB) - - name: "bob" - password_cmd: "echo secretpassword" # Command (output will be hashed in DB) - - name: "charlie" - password: "$2y$12$LU.8xNK6m98hEJ5oRnBsDuMamfIjXoWTW0eMIJ6yGdLoP3nJAHWH6" # Example dummy hash + - name: "daniel" + password: "123" + groups: + - family + - klosterberg + - 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: - - id: "Alice" - owner: "alice" + - id: "school" + owner: "lennart" + - id: "sport" + owner: "lennart" + - id: "family" + owner: "shared" access: - - user: "bob" - mode: "read-only" - - id: "team_project" - owner: "alice" - access: - - user: "bob" + - groups: "family" 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" diff --git a/internal/backend/db.go b/internal/backend/db.go index ca1c321..3cbdff3 100644 --- a/internal/backend/db.go +++ b/internal/backend/db.go @@ -1,6 +1,7 @@ package backend import ( + "slices" "bytes" "context" "errors" @@ -8,6 +9,7 @@ import ( "log" "net/http" "os/exec" + "path" "strings" "github.com/emersion/go-ical" @@ -19,11 +21,21 @@ import ( "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 { - pool *pgxpool.Pool - redactionText string + pool *pgxpool.Pool // Connection pool to PostgreSQL + 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) { pool, err := pgxpool.New(ctx, cfg.Database.URL) if err != nil { @@ -33,12 +45,17 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) { b := &DBBackend{ pool: pool, 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 { return nil, err } + // Step 2: Sync users/calendars from YAML to DB if err := b.syncConfig(ctx, cfg); err != nil { return nil, err } @@ -46,6 +63,9 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) { 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 { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( @@ -84,6 +104,10 @@ func (b *DBBackend) initSchema(ctx context.Context) error { 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) { var raw string 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. - // bcrypt hashes usually start with $2a$ or $2b$ or $2y$. if strings.HasPrefix(raw, "$2") { return raw, nil } - // Otherwise, hash it. + // Hash the raw password before DB entry hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) if err != nil { return "", err @@ -111,6 +134,12 @@ func (b *DBBackend) resolvePassword(u config.User) (string, error) { 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 { tx, err := b.pool.Begin(ctx) if err != nil { @@ -120,10 +149,17 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { configUserNames := 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 { configUserNames[u.Name] = true + for _, g := range u.Groups { + groupMembers[g] = append(groupMembers[g], u.Name) + } hashed, err := b.resolvePassword(u) if err != nil { 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 { path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) configCalendarPaths[path] = true @@ -159,86 +195,118 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { return err } + // Re-build access rules for this calendar _, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID) if err != nil { return err } + calendarAccessModes := make(map[string]string) 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 - 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 { - 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)", - calID, userID, a.Mode) + calID, userID, mode) if err != nil { 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") - if err != nil { - return err - } - var dbUsers []string - for userRows.Next() { - var name string - if err := userRows.Scan(&name); err != nil { - return err + if err == nil { + for userRows.Next() { + var name string + if err := userRows.Scan(&name); err == nil && !configUserNames[name] { + log.Printf("WARNING: Orphaned user found in database: %s", name) + } } - 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") - if err != nil { - return err - } - var dbPaths []string - for calRows.Next() { - 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) + if err == nil { + for calRows.Next() { + var p string + if err := calRows.Scan(&p); err == nil && !configCalendarPaths[p] { + log.Printf("WARNING: Orphaned calendar found in database: %s", p) + } } + calRows.Close() } 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) { var hash string err := b.pool.QueryRow(ctx, "SELECT password FROM users WHERE name = $1", username).Scan(&hash) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return false, nil - } - return false, err + return false, nil // User not found } - + // Securely compare bcrypt hash with the provided plain-text password err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 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) { principal, ok := ctx.Value("principal").(string) if !ok { @@ -247,6 +315,7 @@ func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { 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) { principal, err := b.CurrentUserPrincipal(ctx) if err != nil { @@ -255,6 +324,11 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) { 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) { username, err := b.getUsername(ctx) if err != nil { @@ -279,10 +353,12 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir return 0, err } + // Owners always have full access if ownerName == username { return calID, nil } + // Check the calendar_access table for specific permissions var mode string err = b.pool.QueryRow(ctx, ` SELECT mode FROM calendar_access @@ -295,6 +371,7 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir return 0, err } + // Enforce Read-Only mode if requiredMode == "write" && mode != "read-write" { 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 } -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) 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", calendarID).Scan(&ownerName) - if err != nil { - return nil, err - } - + // Owners see everything unredacted if username == ownerName { return original, nil } @@ -327,15 +406,19 @@ func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, original continue } - class := "PUBLIC" + class := b.defaultClass; + log.Printf("class: %s",class) if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) + log.Printf("class: %s",class) } switch class { case "PRIVATE": + // Don't include private items in the response continue case "CONFIDENTIAL": + // Strip all details except time/meta and set a generic summary redacted := ical.NewComponent(child.Name) propsToKeep := []string{ "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) filtered.Children = append(filtered.Children, redacted) - default: + case "PUBLIC": + log.Printf("pub: %s", child.Name); filtered.Children = append(filtered.Children, child) } } if len(filtered.Children) == 0 { - return nil, nil + return nil, nil // Entire file is hidden } 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) { username, err := b.getUsername(ctx) if err != nil { @@ -370,12 +456,15 @@ func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { 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) { username, err := b.getUsername(ctx) if err != nil { return nil, err } + // Find real calendars (Owned or Shared) rows, err := b.pool.Query(ctx, ` SELECT path, name FROM calendars 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"} 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 } -func (b *DBBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { - if !strings.HasSuffix(path, "/") { - path += "/" +// GetCalendar retrieves metadata for a specific calendar. +// Called when a client wants to verify a calendar's settings. +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 - 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 errors.Is(err, pgx.ErrNoRows) { 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 } +// CreateCalendar is disabled because we manage calendars via config.yaml. func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { 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) { - calID, err := b.checkAccess(ctx, path, "read") +// ListCalendarObjects returns the list of all events/tasks in a calendar. +// 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 { return nil, err } - if !strings.HasSuffix(path, "/") { - path += "/" + 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 { + return nil, err } 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() if err != nil { - return nil, err + continue } - filteredData, err := b.filterCalendar(ctx, calID, calData) - if err != nil { - return nil, err - } - if filteredData == nil { + // Apply privacy filters (Private/Confidential) + filteredData, err := b.filterCalendar(ctx, ownerName, calData) + if err != nil || filteredData == nil { continue } obj.Data = filteredData @@ -461,21 +594,157 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, path string, req *c return res, nil } -func (b *DBBackend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { - lastSlash := strings.LastIndex(path, "/") - if lastSlash == -1 { - return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) - } - parentPath := path[:lastSlash+1] +// listAggregateObjects merges multiple real calendars into one virtual view. +// It rewrites paths so the client thinks items are in the virtual directory. +func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) { + var res []caldav.CalendarObject + + 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 { return nil, err } var obj caldav.CalendarObject 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 errors.Is(err, pgx.ErrNoRows) { 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 } - filteredData, err := b.filterCalendar(ctx, calID, calData) - if err != nil { - return nil, err - } - if filteredData == nil { + // Apply privacy filtering + filteredData, err := b.filterCalendar(ctx, ownerName, calData) + if err != nil || filteredData == nil { return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } obj.Data = filteredData return &obj, nil } -func (b *DBBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { - lastSlash := strings.LastIndex(path, "/") - if lastSlash == -1 { - return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) +// PutCalendarObject saves or updates an event/task. +// Called during PUT requests when a user saves a change in their calendar app. +func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { + 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 { return nil, err } @@ -518,34 +788,36 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, path string, calendar dataStr := buf.String() etag := fmt.Sprintf(`"%d"`, len(calendar.Events())) + // Use Upsert logic to handle both creation and updates _, err = b.pool.Exec(ctx, ` 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`, - calID, path, dataStr, etag) + calID, p, dataStr, etag) if err != nil { return nil, err } return &caldav.CalendarObject{ - Path: path, + Path: p, Data: calendar, ETag: etag, }, nil } -func (b *DBBackend) DeleteCalendarObject(ctx context.Context, path string) error { - lastSlash := strings.LastIndex(path, "/") - if lastSlash == -1 { - return webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) +// DeleteCalendarObject removes an event or task. +// Called during DELETE requests. +func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error { + 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 { 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 { return err } @@ -555,6 +827,11 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, path string) error return nil } -func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { - return b.ListCalendarObjects(ctx, path, nil) +// QueryCalendarObjects filters items based on a CalDAV query (e.g. time range). +// 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) } diff --git a/internal/backend/mem.go b/internal/backend/mem.go index 3b66c27..5b631ad 100644 --- a/internal/backend/mem.go +++ b/internal/backend/mem.go @@ -32,6 +32,13 @@ func NewMemBackend(cfg *config.Config) *MemBackend { 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 { path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) cal := &caldav.Calendar{ @@ -45,8 +52,22 @@ func NewMemBackend(cfg *config.Config) *MemBackend { accessMap := make(map[string]string) for _, a := range c.Access { - accessMap[a.User] = a.Mode - b.userCalendars[a.User] = append(b.userCalendars[a.User], path) + 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 { + if _, exists := accessMap[u]; !exists { + b.userCalendars[u] = append(b.userCalendars[u], path) + } + accessMap[u] = a.Mode + } } b.calendarAccess[path] = accessMap } @@ -121,7 +142,7 @@ func (b *MemBackend) filterCalendar(ctx context.Context, calendarPath string, or continue } - class := "PUBLIC" + class := "CONFIDENTIAL" if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) } diff --git a/internal/config/config.go b/internal/config/config.go index 3b1ce8b..604eaaf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,13 +2,16 @@ package config import ( "os" + "slices" "gopkg.in/yaml.v3" ) type Access struct { - User string `yaml:"user"` - Mode string `yaml:"mode"` // "read-only" or "read-write" + User string `yaml:"user,omitempty"` + Group string `yaml:"group,omitempty"` + Groups string `yaml:"groups,omitempty"` + Mode string `yaml:"mode"` // "read-only" or "read-write" } type Calendar struct { @@ -17,10 +20,18 @@ type Calendar struct { 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 { - Name string `yaml:"name"` - Password string `yaml:"password"` - PasswordCmd string `yaml:"password_cmd"` + Name string `yaml:"name"` + Password string `yaml:"password"` + PasswordCmd string `yaml:"password_cmd"` + Groups []string `yaml:"groups,omitempty"` } type DatabaseConfig struct { @@ -28,16 +39,18 @@ type DatabaseConfig struct { } type ServerConfig struct { - BindAddress string `yaml:"bind_address"` - PublicURL string `yaml:"public_url"` - Redaction string `yaml:"redaction_text"` + BindAddress string `yaml:"bind_address"` + PublicURL string `yaml:"public_url"` + Redaction string `yaml:"redaction_text"` + DefaultClass string `yaml:"default_class"` } type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - Users []User `yaml:"users"` - Calendars []Calendar `yaml:"calendars"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Users []User `yaml:"users"` + Calendars []Calendar `yaml:"calendars"` + Aggregates []Aggregate `yaml:"aggregates"` } func Load(path string) (*Config, error) { @@ -53,10 +66,17 @@ func Load(path string) (*Config, error) { return nil, err } + cfg.checkConfig() cfg.setDefaults() 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() { if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" @@ -64,6 +84,9 @@ func (c *Config) setDefaults() { if c.Server.Redaction == "" { c.Server.Redaction = "Busy" } + if c.Server.DefaultClass == "" { + c.Server.DefaultClass = "CONFIDENTIAL" + } if c.Database.URL == "" { c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" } diff --git a/main.go b/main.go index d0f34c8..ac70170 100644 --- a/main.go +++ b/main.go @@ -34,7 +34,7 @@ func main() { return } - // Verify user against database (bcrypt) + // Verify via Database (bcrypt) valid, err := be.VerifyUser(r.Context(), user, password) if err != nil { log.Printf("auth error for %s: %v", user, err) @@ -43,11 +43,12 @@ func main() { } if !valid { + log.Printf("auth failed for %s", user) http.Error(w, "Unauthorized", http.StatusUnauthorized) 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) ctx := context.WithValue(r.Context(), "principal", principalPath) @@ -63,8 +64,8 @@ func main() { fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress) server := &http.Server{ Addr: cfg.Server.BindAddress, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, } if err := server.ListenAndServe(); err != nil { log.Fatalf("server failed: %v", err) diff --git a/nxcaldav b/nxcaldav deleted file mode 100755 index 976a297..0000000 Binary files a/nxcaldav and /dev/null differ diff --git a/orphan_test.log b/orphan_test.log deleted file mode 100644 index a5b4772..0000000 --- a/orphan_test.log +++ /dev/null @@ -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... diff --git a/test.py b/test.py index 1627883..ecd50fe 100644 --- a/test.py +++ b/test.py @@ -1,49 +1,8 @@ import os import yaml -import subprocess from caldav import DAVClient -from ics import Calendar, Todo -from datetime import datetime, timedelta - -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""") +from ics import Calendar +from datetime import datetime if __name__ == "__main__": with open("config.yaml") as f: @@ -51,36 +10,26 @@ if __name__ == "__main__": url = "http://localhost:8080/" - alice_cfg = next(u for u in config['users'] if u['name'] == 'alice') - alice_pw = get_password(alice_cfg) - alice_client = DAVClient(url, username=alice_cfg['name'], password=alice_pw) - 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() + # Diane checks the aggregate 'lennart' calendar + diane_client = DAVClient(url, username="diane", password="123") + d_principal = diane_client.principal() - print("\n--- Bob viewing Alice's calendar ---") - bob_alice_personal = next(c for c in bob_principal.calendars() if "/alice/calendars/personal/" in c.url.path) - - print("Checking Events...") - events = bob_alice_personal.events() + print("\n--- Diane viewing Aggregate 'lennart' calendar ---") + agg_path = "/lennart/calendars/lennart/" + lennart_agg = next(c for c in d_principal.calendars() if agg_path in c.url.path) + + events = lennart_agg.events() + print(f"Events found in aggregate: {len(events)}") for e in events: - c = Calendar(e.data) - for ev in c.events: - print(f" - Event: '{ev.name}' (UID: {ev.uid})") - - print("Checking Todos...") - todos = bob_alice_personal.todos() - for t in todos: - c = Calendar(t.data) - for td in c.todos: - print(f" - Todo: '{td.name}' (UID: {td.uid})") + print(f" - Path: {e.url.path}") + if not e.url.path.startswith(agg_path): + print(f" ERROR: Path {e.url.path} is NOT under the aggregate {agg_path}!") + + # Test individual GET (GetCalendarObject) + try: + data = e.data + c = Calendar(data) + for ev in c.events: + print(f" Fetched: {ev.name} (UID: {ev.uid})") + except Exception as err: + print(f" ERROR fetching individual item: {err}") diff --git a/verify_group.py b/verify_group.py new file mode 100644 index 0000000..e1cb7b3 --- /dev/null +++ b/verify_group.py @@ -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()