commit 41e36a45457bef9b75b4d6130c77194983c8ef85 Author: Lennart J. Kurzweg (Nx2) Date: Sat Mar 21 02:39:09 2026 +0100 init diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f4317c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.direnv +server.log diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..8f4317c --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +.direnv +server.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..da505a6 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Postgres +- url + - Format: `postgres://user@host:port/dbname` or `postgres:///dbname?host=/var/run/postgresql`. + - Default: `postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable` + +# Users + +- name: required +- password: cleartext or bcrypt hash +- password_cmd: shell command + +# SQL +## delte user +```sql +DELETE FROM users WHERE name = 'bob'; +``` + +## delete calendar +```sql +DELETE FROM calendars WHERE name = 'bob_calendar' AND owner_id = (SELECT id FROM users WHERE name = 'bob'); +/* or */ +DELETE FROM calendars WHERE path = '/bob/calendars/bob_calendar/'; +``` diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..997ed3b --- /dev/null +++ b/config.yaml @@ -0,0 +1,29 @@ +server: + bind_address: "0.0.0.0:8080" + public_url: "http://localhost:8080" + redaction_text: "Busy (Private)" + +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 + +calendars: + - id: "Alice" + owner: "alice" + access: + - user: "bob" + mode: "read-only" + - id: "team_project" + owner: "alice" + access: + - user: "bob" + mode: "read-write" + - user: "charlie" + mode: "read-only" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4353797 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module nxcaldav + +go 1.25.7 + +require ( + github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 + github.com/emersion/go-webdav v0.7.0 + github.com/jackc/pgx/v5 v5.8.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ad43a1 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backend/db.go b/internal/backend/db.go new file mode 100644 index 0000000..ca1c321 --- /dev/null +++ b/internal/backend/db.go @@ -0,0 +1,560 @@ +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) +} diff --git a/internal/backend/mem.go b/internal/backend/mem.go new file mode 100644 index 0000000..3b66c27 --- /dev/null +++ b/internal/backend/mem.go @@ -0,0 +1,318 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + "nxcaldav/internal/config" +) + +type MemBackend struct { + sync.RWMutex + allCalendars map[string]*caldav.Calendar + objects map[string]map[string]*caldav.CalendarObject + userCalendars map[string][]string + calendarOwner map[string]string + calendarAccess map[string]map[string]string +} + +func NewMemBackend(cfg *config.Config) *MemBackend { + b := &MemBackend{ + allCalendars: make(map[string]*caldav.Calendar), + objects: make(map[string]map[string]*caldav.CalendarObject), + userCalendars: make(map[string][]string), + calendarOwner: make(map[string]string), + calendarAccess: make(map[string]map[string]string), + } + + for _, c := range cfg.Calendars { + path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) + cal := &caldav.Calendar{ + Path: path, + Name: c.ID, + SupportedComponentSet: []string{"VEVENT", "VTODO"}, + } + b.allCalendars[path] = cal + b.calendarOwner[path] = c.Owner + b.userCalendars[c.Owner] = append(b.userCalendars[c.Owner], path) + + 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) + } + b.calendarAccess[path] = accessMap + } + + return b +} + +func (b *MemBackend) 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 *MemBackend) getUsername(ctx context.Context) (string, error) { + principal, err := b.CurrentUserPrincipal(ctx) + if err != nil { + return "", err + } + return strings.Trim(principal, "/"), nil +} + +func (b *MemBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) error { + username, err := b.getUsername(ctx) + if err != nil { + return err + } + + if !strings.HasSuffix(calendarPath, "/") { + calendarPath += "/" + } + + owner := b.calendarOwner[calendarPath] + if owner == username { + return nil + } + + mode, ok := b.calendarAccess[calendarPath][username] + if !ok { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied")) + } + + if requiredMode == "write" && mode != "read-write" { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) + } + + return nil +} + +func (b *MemBackend) filterCalendar(ctx context.Context, calendarPath string, original *ical.Calendar) (*ical.Calendar, error) { + username, err := b.getUsername(ctx) + if err != nil { + return nil, err + } + + if !strings.HasSuffix(calendarPath, "/") { + calendarPath += "/" + } + + owner := b.calendarOwner[calendarPath] + if username == owner { + 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", "Busy") + 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 *MemBackend) 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 *MemBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { + username, err := b.getUsername(ctx) + if err != nil { + return nil, err + } + + b.RLock() + defer b.RUnlock() + + paths := b.userCalendars[username] + res := make([]caldav.Calendar, 0, len(paths)) + for _, p := range paths { + if cal, ok := b.allCalendars[p]; ok { + res = append(res, *cal) + } + } + return res, nil +} + +func (b *MemBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { + b.RLock() + defer b.RUnlock() + + if !strings.HasSuffix(path, "/") { + path += "/" + } + + if cal, ok := b.allCalendars[path]; ok { + return cal, nil + } + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) +} + +func (b *MemBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config")) +} + +func (b *MemBackend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + if err := b.checkAccess(ctx, path, "read"); err != nil { + return nil, err + } + + b.RLock() + defer b.RUnlock() + + if !strings.HasSuffix(path, "/") { + path += "/" + } + + objs, ok := b.objects[path] + if !ok { + return []caldav.CalendarObject{}, nil + } + + res := make([]caldav.CalendarObject, 0, len(objs)) + for _, obj := range objs { + filteredData, err := b.filterCalendar(ctx, path, obj.Data) + if err != nil { + return nil, err + } + if filteredData == nil { + continue + } + newObj := *obj + newObj.Data = filteredData + res = append(res, newObj) + } + return res, nil +} + +func (b *MemBackend) 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] + + if err := b.checkAccess(ctx, parentPath, "read"); err != nil { + return nil, err + } + + b.RLock() + defer b.RUnlock() + + if objs, ok := b.objects[parentPath]; ok { + if obj, ok := objs[path]; ok { + filteredData, err := b.filterCalendar(ctx, parentPath, obj.Data) + if err != nil { + return nil, err + } + if filteredData == nil { + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) + } + newObj := *obj + newObj.Data = filteredData + return &newObj, nil + } + } + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) +} + +func (b *MemBackend) 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] + + if err := b.checkAccess(ctx, parentPath, "write"); err != nil { + return nil, err + } + + b.Lock() + defer b.Unlock() + + obj := &caldav.CalendarObject{ + Path: path, + Data: calendar, + ETag: fmt.Sprintf(`"%d"`, len(calendar.Events())), + } + + if _, ok := b.objects[parentPath]; !ok { + b.objects[parentPath] = make(map[string]*caldav.CalendarObject) + } + b.objects[parentPath][path] = obj + return obj, nil +} + +func (b *MemBackend) 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] + + if err := b.checkAccess(ctx, parentPath, "write"); err != nil { + return err + } + + b.Lock() + defer b.Unlock() + + if objs, ok := b.objects[parentPath]; ok { + delete(objs, path) + return nil + } + return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) +} + +func (b *MemBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + return b.ListCalendarObjects(ctx, path, nil) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3b1ce8b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Access struct { + User string `yaml:"user"` + Mode string `yaml:"mode"` // "read-only" or "read-write" +} + +type Calendar struct { + ID string `yaml:"id"` + Owner string `yaml:"owner"` + Access []Access `yaml:"access,omitempty"` +} + +type User struct { + Name string `yaml:"name"` + Password string `yaml:"password"` + PasswordCmd string `yaml:"password_cmd"` +} + +type DatabaseConfig struct { + URL string `yaml:"url"` +} + +type ServerConfig struct { + BindAddress string `yaml:"bind_address"` + PublicURL string `yaml:"public_url"` + Redaction string `yaml:"redaction_text"` +} + +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Users []User `yaml:"users"` + Calendars []Calendar `yaml:"calendars"` +} + +func Load(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var cfg Config + err = yaml.NewDecoder(f).Decode(&cfg) + if err != nil { + return nil, err + } + + cfg.setDefaults() + return &cfg, nil +} + +func (c *Config) setDefaults() { + if c.Server.BindAddress == "" { + c.Server.BindAddress = ":8080" + } + if c.Server.Redaction == "" { + c.Server.Redaction = "Busy" + } + if c.Database.URL == "" { + c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d0f34c8 --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/emersion/go-webdav/caldav" + "nxcaldav/internal/backend" + "nxcaldav/internal/config" +) + +func main() { + cfg, err := config.Load("config.yaml") + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + ctx := context.Background() + be, err := backend.NewDBBackend(ctx, cfg) + if err != nil { + log.Fatalf("failed to initialize database backend: %v", err) + } + + handler := &caldav.Handler{Backend: be} + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Verify user against database (bcrypt) + valid, err := be.VerifyUser(r.Context(), user, password) + if err != nil { + log.Printf("auth error for %s: %v", user, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if !valid { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + log.Printf("%s %s %s", user, r.Method, r.URL.Path) + + principalPath := fmt.Sprintf("/%s/", user) + ctx := context.WithValue(r.Context(), "principal", principalPath) + + if r.URL.Path == "/.well-known/caldav" { + http.Redirect(w, r, principalPath, http.StatusMovedPermanently) + return + } + + handler.ServeHTTP(w, r.WithContext(ctx)) + }) + + 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, + } + if err := server.ListenAndServe(); err != nil { + log.Fatalf("server failed: %v", err) + } +} diff --git a/nxcaldav b/nxcaldav new file mode 100755 index 0000000..976a297 Binary files /dev/null and b/nxcaldav differ diff --git a/orphan_test.log b/orphan_test.log new file mode 100644 index 0000000..a5b4772 --- /dev/null +++ b/orphan_test.log @@ -0,0 +1,2 @@ +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/shell.nix b/shell.nix new file mode 100644 index 0000000..652670a --- /dev/null +++ b/shell.nix @@ -0,0 +1,11 @@ +{ pkgs ? import { } }: let + my-python = pkgs.python312; + python-with-my-packages = my-python.withPackages (p: with p; [ + ical + ics + caldav + pyyaml + ]); +in pkgs.mkShell { + buildInputs = [ python-with-my-packages ]; +} diff --git a/test.py b/test.py new file mode 100644 index 0000000..1627883 --- /dev/null +++ b/test.py @@ -0,0 +1,86 @@ +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""") + +if __name__ == "__main__": + with open("config.yaml") as f: + config = yaml.safe_load(f) + + 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() + + 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() + 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})")