package backend import ( "slices" "bytes" "context" "errors" "fmt" "log" "net/http" "os/exec" "path" "strings" "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "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 // 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 { return nil, fmt.Errorf("failed to connect to postgres: %v", err) } 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 } 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 ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, password TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS calendars ( id SERIAL PRIMARY KEY, owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE, path TEXT UNIQUE NOT NULL, name TEXT, description TEXT )`, `CREATE TABLE IF NOT EXISTS calendar_access ( calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, mode TEXT NOT NULL, PRIMARY KEY (calendar_id, user_id) )`, `CREATE TABLE IF NOT EXISTS calendar_objects ( id SERIAL PRIMARY KEY, calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, path TEXT NOT NULL, data TEXT NOT NULL, etag TEXT NOT NULL, UNIQUE (calendar_id, path) )`, } for _, q := range queries { if _, err := b.pool.Exec(ctx, q); err != nil { return fmt.Errorf("failed to execute schema query: %v", err) } } 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 != "" { cmd := exec.Command("bash", "-c", u.PasswordCmd) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err) } raw = strings.TrimSpace(string(out)) } else { raw = u.Password } // If it already looks like a bcrypt hash, return as is. if strings.HasPrefix(raw, "$2") { return raw, nil } // Hash the raw password before DB entry hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) if err != nil { return "", err } 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 { return err } defer tx.Rollback(ctx) configUserNames := make(map[string]bool) configCalendarPaths := make(map[string]bool) groupMembers := make(map[string][]string) 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 } _, err = tx.Exec(ctx, ` INSERT INTO users (name, password) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET password = EXCLUDED.password`, u.Name, hashed) if err != nil { return err } } // --- Phase 2: Calendar & Access Sync --- for _, c := range cfg.Calendars { path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) configCalendarPaths[path] = true var ownerID int err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", c.Owner).Scan(&ownerID) if err != nil { return fmt.Errorf("owner %s not found: %v", c.Owner, err) } var calID int err = tx.QueryRow(ctx, ` INSERT INTO calendars (owner_id, path, name) VALUES ($1, $2, $3) ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name RETURNING id`, ownerID, path, c.ID).Scan(&calID) if err != nil { 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", uName).Scan(&userID) if err != nil { 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, mode) if err != nil { return err } } } // --- 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 { 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) } } userRows.Close() } calRows, err := tx.Query(ctx, "SELECT path FROM calendars") 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 { 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 --- // 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 { return "", errors.New("no principal in context") } 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 { return "", err } 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 { return 0, err } if !strings.HasSuffix(calendarPath, "/") { calendarPath += "/" } 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`, calendarPath).Scan(&calID, &ownerName) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) } 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 WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`, calID, username).Scan(&mode) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied")) } return 0, err } // Enforce Read-Only mode if requiredMode == "write" && mode != "read-write" { return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) } return calID, nil } // 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 } // Owners see everything unredacted if username == ownerName { return original, nil } filtered := ical.NewCalendar() filtered.Props = original.Props for _, child := range original.Children { if child.Name != "VEVENT" && child.Name != "VTODO" { filtered.Children = append(filtered.Children, child) continue } class := b.defaultClass; if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) } 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", "DUE", "COMPLETED", "STATUS", "PRIORITY", "PERCENT-COMPLETE", } for _, pName := range propsToKeep { if props, ok := child.Props[pName]; ok { redacted.Props[pName] = props } } redacted.Props.SetText("SUMMARY", b.redactionText) filtered.Children = append(filtered.Children, redacted) case "PUBLIC": filtered.Children = append(filtered.Children, child) } } if len(filtered.Children) == 0 { return nil, nil // Entire file is hidden } return filtered, nil } // --- 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 { return "", err } 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) OR id IN (SELECT calendar_id FROM calendar_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`, username) if err != nil { return nil, err } defer rows.Close() var res []caldav.Calendar for rows.Next() { var cal caldav.Calendar if err := rows.Scan(&cal.Path, &cal.Name); err != nil { return nil, err } 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 } // 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", 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")) } return nil, err } cal.SupportedComponentSet = []string{"VEVENT", "VTODO"} return &cal, nil } // CreateCalendar is disabled because calendars are managed via the config func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config")) } // 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 } 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) if err != nil { return nil, err } defer rows.Close() var res []caldav.CalendarObject for rows.Next() { var obj caldav.CalendarObject var dataStr string if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil { return nil, err } calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() if err != nil { continue } // Apply privacy filters (Private/Confidential) filteredData, err := b.filterCalendar(ctx, ownerName, calData) if err != nil || filteredData == nil { continue } obj.Data = filteredData res = append(res, obj) } return res, nil } // 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 } 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" { descr := child.Props.Get("DESCRIPTION") val := "" if descr != nil { val = descr.Value } child.Props.SetText("DESCRIPTION", fmt.Sprintf("[%s]\n%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, 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")) } return nil, err } calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() if err != nil { return nil, err } // 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 } // 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")) } calID, err := b.checkAccess(ctx, dirPath, "write") if err != nil { return nil, err } var buf bytes.Buffer if err := ical.NewEncoder(&buf).Encode(calendar); err != nil { return nil, err } 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, p, dataStr, etag) if err != nil { return nil, err } return &caldav.CalendarObject{ Path: p, Data: calendar, ETag: etag, }, nil } // 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")) } 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, p) if err != nil { return err } if commandTag.RowsAffected() == 0 { return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } return 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) }