package backend import ( "slices" "bytes" "context" "errors" "fmt" "log" "net/http" "net/url" "os/exec" "path" "strings" "time" "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" ) type publicInfo struct { InternalPath string Mode string // e.g., "future-only" } // 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 prefix string // Public URL base path prefix publicURL string // Full public URL base (e.g. http://nxc.nx2.site/) redactionText string // Text used to hide confidential event details (e.g. "[REDACED]") defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL") emailDomain string // Domain for email addresses (e.g., "nx2.site") smtp config.SMTPConfig // SMTP server configuration 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 publicAccess map[string]publicInfo // In-memory map of public path -> internal info } // 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, prefix: cfg.Server.BasePath(), publicURL: cfg.Server.PublicURL, redactionText: cfg.Server.Redaction, defaultClass: cfg.Server.DefaultClass, emailDomain: cfg.Server.EmailDomain, smtp: cfg.SMTP, aggregates: make(map[string]*config.Aggregate), userAggs: make(map[string][]string), publicAccess: make(map[string]publicInfo), } // 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, color 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) b.publicAccess = make(map[string]publicInfo) prefix := cfg.Server.BasePath() // --- 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 := prefix + 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, color) VALUES ($1, $2, $3, $4) ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color RETURNING id`, ownerID, path, c.ID, c.Color).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 } } // Check for public access (ICS) for _, a := range c.Access { if a.ICS != "" { pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID) b.publicAccess[pubPath] = publicInfo{ InternalPath: path, Mode: a.ICS, } } } } // --- Phase 3: Aggregate Setup --- // Aggregates are virtual, so we only track them in memory for routing. for _, agg := range cfg.Aggregates { p_ := prefix + 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 { if a.ICS != "" { pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", agg.Owner, agg.ID) b.publicAccess[pubPath] = publicInfo{ InternalPath: p_, Mode: a.ICS, } } 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 "", nil // No principal means anonymous/public } 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 and the granted mode 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, string, 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, "owner", 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, mode, 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 (unless they have read-write access). // 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, mode string, original *ical.Calendar) (*ical.Calendar, error) { username, err := b.getUsername(ctx) if err != nil { return nil, err } // Owners and users with read-write access see everything unredacted. // We check both username and mode for robustness. if username == ownerName || mode == "owner" || mode == "read-write" { 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 } // ServePublicICS handles public GET requests for .ics files. // It applies privacy redaction and optional time-range filtering (e.g. "future-only"). func (b *DBBackend) ServePublicICS(w http.ResponseWriter, r *http.Request) { info, ok := b.publicAccess[r.URL.Path] if !ok { http.Error(w, "Not Found", http.StatusNotFound) return } // For public access, we use an empty context (no principal) // which ensures filterCalendar applies full redaction. ctx := r.Context() var objects []caldav.CalendarObject var err error if agg, ok := b.aggregates[info.InternalPath]; ok { objects, err = b.listAggregateObjects(ctx, info.InternalPath, agg) } else { 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`, info.InternalPath).Scan(&calID, &ownerName) if err == nil { objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName, "read") } } if err != nil { log.Printf("Error fetching public objects: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } outCal := ical.NewCalendar() outCal.Props.SetText("PRODID", "-//nxcaldav//Public ICS//EN") outCal.Props.SetText("VERSION", "2.0") now := time.Now() for _, obj := range objects { for _, child := range obj.Data.Children { if child.Name != "VEVENT" && child.Name != "VTODO" { continue } if info.Mode == "future-only" { var end time.Time if prop := child.Props.Get("DTEND"); prop != nil { end, _ = prop.DateTime(time.UTC) } else if prop := child.Props.Get("DTSTART"); prop != nil { end, _ = prop.DateTime(time.UTC) } if !end.IsZero() && end.Before(now) { continue } } outCal.Children = append(outCal.Children, child) } } w.Header().Set("Content-Type", "text/calendar; charset=utf-8") if err := ical.NewEncoder(w).Encode(outCal); err != nil { log.Printf("Error encoding public ICS: %v", err) } } // --- 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 b.prefix + 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("[user: %s] ListCalendarObjects %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, mode, 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 } return b.listCalendarObjectsRaw(ctx, calID, ownerName, mode) } func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string, mode string) ([]caldav.CalendarObject, error) { 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, mode, 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 username, _ := b.getUsername(ctx) for _, sourceID := range agg.Sources { sourcePath := b.prefix + 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 } // Determine access mode for this user on this source calendar mode := "read" if username == ownerName { mode = "owner" } else if username != "" { 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 { mode = "read" // Assume read-only if no explicit entry } } objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName, mode) if err != nil { continue } for _, obj := range objs { // Prepend source name so Diane knows this is a "[calendar]" event for _, child := range obj.Data.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)) } } // Virtualize the path so Thunderbird doesn't reject the file fileName := path.Base(obj.Path) obj.Path = aggPath + sourceID + "__" + fileName res = append(res, obj) } } 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("[user: %s] GetCalendarObject %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 := b.prefix + 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 } // Determine access mode for this user on this source calendar mode := "read" if username == ownerName { mode = "owner" } else if username != "" { 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 { mode = "read" // Assume read-only if no explicit entry } } filteredData, err := b.filterCalendar(ctx, ownerName, mode, 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, mode, 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, mode, 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 } // Step 3: Handle Status Propagation & Invitations username, _ := b.getUsername(ctx) userEmail := strings.ToLower(username + "@" + b.emailDomain) for _, event := range calendar.Events() { organizer := event.Props.Get("ORGANIZER") orgEmail := "" if organizer != nil { orgEmail = strings.TrimPrefix(strings.ToLower(organizer.Value), "mailto:") } // --- Case A: User is the ATTENDEE updating their status --- // If the user is NOT the organizer, but is an attendee, find the organizer's // original event and update the status there. if orgEmail != "" && orgEmail != userEmail { log.Printf("[scheduling] Attendee %s updated event. Propagating to organizer %s", userEmail, orgEmail) // Find the attendee's status in this version myStatus := "NEEDS-ACTION" for _, att := range event.Props["ATTENDEE"] { if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == userEmail { if stat := att.Params.Get("PARTSTAT"); stat != "" { myStatus = stat } break } } // Find the UID of this event to locate the organizer's copy uid := "" if u := event.Props.Get("UID"); u != nil { uid = u.Value } if uid != "" { // Search for the organizer's copy in their calendars go b.propagateStatusToOrganizer(orgEmail, userEmail, uid, myStatus) } continue // Don't send invitations from an attendee's PUT } // --- Case B: User is the ORGANIZER (Sending Invitations) --- // Only send invites if the user is the organizer and we are in the owner's calendar isOwnerCalendar := strings.Contains(p, "/"+username+"/") if !isOwnerCalendar || (orgEmail != "" && orgEmail != userEmail) { continue } attendees := event.Props["ATTENDEE"] if len(attendees) == 0 { continue } summary := "" if s := event.Props.Get("SUMMARY"); s != nil { summary = s.Value } description := "" if d := event.Props.Get("DESCRIPTION"); d != nil { description = d.Value } start := "" if dtstart, err := event.DateTimeStart(time.UTC); err == nil { start = dtstart.Format(time.RFC1123) } end := "" if dtend, err := event.DateTimeEnd(time.UTC); err == nil { end = dtend.Format(time.RFC1123) } for _, attendee := range attendees { recipientEmail := strings.TrimPrefix(attendee.Value, "mailto:") if recipientEmail == "" { continue } // Only send if it's a valid looking email and not the sender themselves if strings.Contains(recipientEmail, "@") && !strings.HasPrefix(recipientEmail, username+"@") { go b.sendInvitation(username, recipientEmail, summary, description, start, end, p, dataStr) } } } 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 } // propagateStatusToOrganizer finds the original event in the organizer's calendar // and updates the attendee's status. func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, status string) { ctx := context.Background() log.Printf("[scheduling] Searching for original event UID %s for organizer %s", uid, orgEmail) // 1. Find the organizer's user ID var orgUserID int err := b.pool.QueryRow(ctx, "SELECT id FROM users WHERE name = $1 OR name = $2", strings.Split(orgEmail, "@")[0], orgEmail).Scan(&orgUserID) if err != nil { log.Printf("[scheduling] Could not find organizer user %s: %v", orgEmail, err) return } // 2. Find the calendar object by UID within organizer's calendars rows, err := b.pool.Query(ctx, ` SELECT co.path, co.data, co.calendar_id FROM calendar_objects co JOIN calendars c ON co.calendar_id = c.id WHERE c.owner_id = $1 AND co.data LIKE '%' || $2 || '%'`, orgUserID, uid) if err != nil { log.Printf("[scheduling] Error searching for organizer's copy: %v", err) return } defer rows.Close() for rows.Next() { var p, dataStr string var calID int if err := rows.Scan(&p, &dataStr, &calID); err != nil { continue } // Verify UID (LIKE is just a hint) calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() if err != nil { continue } found := false for _, event := range calendar.Events() { if u := event.Props.Get("UID"); u != nil && u.Value == uid { // Update attendee status for _, att := range event.Props["ATTENDEE"] { if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == attendeeEmail { log.Printf("[scheduling] Updating %s status to %s in organizer's copy %s", attendeeEmail, status, p) att.Params.Set("PARTSTAT", status) found = true } } } } if found { var buf bytes.Buffer if err := ical.NewEncoder(&buf).Encode(calendar); err == nil { newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), buf.Len()) b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4", buf.String(), newEtag, calID, p) log.Printf("[scheduling] Organizer's copy %s updated successfully", p) } } } } // RespondToInvitation handles an attendee's Accept/Decline response. func (b *DBBackend) RespondToInvitation(ctx context.Context, p, attendeeEmail, status string) error { log.Printf("[email] Response for %s from %s: %s", p, attendeeEmail, status) // 1. Fetch the calendar object var dataStr string var calID int err := b.pool.QueryRow(ctx, "SELECT calendar_id, data FROM calendar_objects WHERE path = $1", p).Scan(&calID, &dataStr) if err != nil { return fmt.Errorf("failed to find calendar object: %v", err) } calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() if err != nil { return fmt.Errorf("failed to decode calendar data: %v", err) } // 2. Update PARTSTAT for the attendee modified := false status = strings.ToUpper(status) attendeeEmail = strings.ToLower(strings.TrimSpace(attendeeEmail)) for _, event := range calendar.Events() { attendees := event.Props["ATTENDEE"] for _, attendee := range attendees { email := strings.TrimPrefix(strings.ToLower(attendee.Value), "mailto:") if email == attendeeEmail { attendee.Params.Set("PARTSTAT", status) modified = true } } } if !modified { return fmt.Errorf("attendee %s not found in event", attendeeEmail) } // 3. Save back to DB var buf bytes.Buffer if err := ical.NewEncoder(&buf).Encode(calendar); err != nil { return err } newDataStr := buf.String() // Use a timestamp + length for a unique ETag newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), len(newDataStr)) _, err = b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4", newDataStr, newEtag, calID, p) if err != nil { return fmt.Errorf("failed to update calendar object in DB: %v", err) } log.Printf("[email] Successfully updated status to %s for %s in %s", status, attendeeEmail, p) 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("[user: %s] QueryCalendarObjects %s", username, path_) return b.ListCalendarObjects(ctx, path_, nil) } func (b *DBBackend) GetColor(ctx context.Context, p string) string { // If p is a full URL, extract the path if strings.HasPrefix(p, "http") { if u, err := url.Parse(p); err == nil { p = u.Path } } if !strings.HasSuffix(p, "/") { p += "/" } // Check if it's an aggregate if agg, ok := b.aggregates[p]; ok { return agg.Color } // Check if it's a real calendar var color string err := b.pool.QueryRow(ctx, "SELECT color FROM calendars WHERE path = $1", p).Scan(&color) if err == nil { return color } return "" }