package backend import ( "bytes" "context" "errors" "fmt" "log" "net/http" "os/exec" "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" ) type DBBackend struct { pool *pgxpool.Pool redactionText string } 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, } if err := b.initSchema(ctx); err != nil { return nil, err } if err := b.syncConfig(ctx, cfg); err != nil { return nil, err } return b, nil } 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 } 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. // bcrypt hashes usually start with $2a$ or $2b$ or $2y$. if strings.HasPrefix(raw, "$2") { return raw, nil } // Otherwise, hash it. hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } 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) // Sync Users for _, u := range cfg.Users { configUserNames[u.Name] = true 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 } } // Sync Calendars and Access 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 } _, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID) if err != nil { return err } for _, a := range c.Access { var userID int err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", a.User).Scan(&userID) if err != nil { return fmt.Errorf("access user %s not found: %v", a.User, err) } _, err = tx.Exec(ctx, "INSERT INTO calendar_access (calendar_id, user_id, mode) VALUES ($1, $2, $3)", calID, userID, a.Mode) if err != nil { return err } } } // Orphaned User Check 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 } dbUsers = append(dbUsers, name) } 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) } } return tx.Commit(ctx) } 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 } err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil, nil } // UserPrincipalBackend implementation 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 } func (b *DBBackend) getUsername(ctx context.Context) (string, error) { principal, err := b.CurrentUserPrincipal(ctx) if err != nil { return "", err } return strings.Trim(principal, "/"), nil } 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 } if ownerName == username { return calID, nil } 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 } if requiredMode == "write" && mode != "read-write" { return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) } return calID, nil } func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, 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 } 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 := "PUBLIC" if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) } switch class { case "PRIVATE": continue case "CONFIDENTIAL": 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) default: filtered.Children = append(filtered.Children, child) } } if len(filtered.Children) == 0 { return nil, nil } return filtered, nil } // CalDAV Backend implementation 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 } func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { username, err := b.getUsername(ctx) if err != nil { return nil, err } 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) } return res, nil } func (b *DBBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { if !strings.HasSuffix(path, "/") { path += "/" } var cal caldav.Calendar err := b.pool.QueryRow(ctx, "SELECT path, name FROM calendars WHERE path = $1", path).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 } 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") if err != nil { return nil, err } if !strings.HasSuffix(path, "/") { path += "/" } 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 { return nil, err } filteredData, err := b.filterCalendar(ctx, calID, calData) if err != nil { return nil, err } if filteredData == nil { continue } obj.Data = filteredData res = append(res, obj) } 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] calID, err := b.checkAccess(ctx, parentPath, "read") 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) 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 } filteredData, err := b.filterCalendar(ctx, calID, calData) if err != nil { return nil, err } if 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")) } parentPath := path[:lastSlash+1] calID, err := b.checkAccess(ctx, parentPath, "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())) _, 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) if err != nil { return nil, err } return &caldav.CalendarObject{ Path: path, 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")) } parentPath := path[:lastSlash+1] calID, err := b.checkAccess(ctx, parentPath, "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) if err != nil { return err } if commandTag.RowsAffected() == 0 { return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } return nil } func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { return b.ListCalendarObjects(ctx, path, nil) }