4 Commits

Author SHA1 Message Date
Lennart J. Kurzweg (Nx2)
e0796a071b cardav working (tm) 2026-03-31 02:08:39 +02:00
Lennart J. Kurzweg (Nx2)
ce78c6e07f email kinda working 2026-03-30 22:18:58 +02:00
Lennart J. Kurzweg (Nx2)
057ba02865 progress 2026-03-24 23:27:14 +01:00
Lennart J. Kurzweg (Nx2)
f961440f13 fix path 2026-03-23 22:20:16 +01:00
10 changed files with 1126 additions and 111 deletions

View File

@@ -1,4 +1,4 @@
.direnv .direnv
server.log # server.log
mem.go mem.go
nxcaldav nxcaldav

View File

@@ -21,7 +21,7 @@ DELETE FROM users WHERE name = 'bob';
```sql ```sql
DELETE FROM calendars WHERE name = 'bob_calendar' AND owner_id = (SELECT id FROM users WHERE name = 'bob'); DELETE FROM calendars WHERE name = 'bob_calendar' AND owner_id = (SELECT id FROM users WHERE name = 'bob');
/* or */ /* or */
DELETE FROM calendars WHERE ; DELETE FROM calendars WHERE path = '/bob/calendars/old/';
``` ```
## rename calendar ## rename calendar
```sql ```sql

View File

@@ -1,42 +1,76 @@
server:
bind_address: "0.0.0.0:14243"
public_url: "http://localhost:8080"
redaction_text: "[-]"
default_class: "CONFIDENTIAL"
database: database:
url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" url: postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable
server:
bind_address: 0.0.0.0:14243
default_class: CONFIDENTIAL
public_url: http://nxc.nx2.site/
email_domain: nx2.site
redaction_text: '[-]'
smtp:
host: localhost
port: 587
user: nxcaldav@nx2.site
password: Vastly-Wrinkle9-Corsage
users: users:
- name: "daniel" - name: daniel
password: "Cyclist-Hypnotize7-Blurb" password: ll
groups: groups:
- family - family
- name: "diane" - name: lennart
password: "Carve-Unluckily-Reprint1" password: ll
groups: groups:
- family - family
- name: "lennart" - name: shared
password: "Baton6-Extortion-Monologue" password: Oxidant-Ageless3-Dispersed
groups:
- family
- name: "shared"
password: "Oxidant-Ageless3-Dispersed"
calendars: calendars:
- id: "default" - id: preservation
owner: "lennart" owner: lennart
- id: "family" color: '#F6F5F4'
owner: "shared" - id: effort
owner: lennart
color: '#FF0000'
- id: experience
owner: lennart
color: '#2C33FF'
- id: leisure
owner: lennart
color: '#10B400'
- id: daniel
owner: daniel
color: '#ff2222'
- access:
- group: family
mode: read-write
id: family
owner: shared
color: '#999999'
address_books:
- id: contacts
owner: lennart
- id: contacts
owner: daniel
- id: family
owner: shared
access: access:
- groups: "family" - group: family
mode: "read-write" mode: read-write
aggregates: aggregates:
- id: "lennart_aggregate" - access:
owner: "shared" - group: family
sources: [ "default", "family" ] mode: read-only
access: - ics: future-only
- group: "diane" id: lennart-aggregat
mode: "read-only" owner: lennart
- ics: "future-only" color: '#dd9999'
sources:
- preservation
- effort
- experience
- leisure
- family

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
) )
require ( require (
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect

2
go.sum
View File

@@ -6,6 +6,8 @@ github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegN
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= 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-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-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/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 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -8,15 +8,19 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"os/exec" "os/exec"
"path" "path"
"strings" "strings"
"time" "time"
"github.com/emersion/go-ical" "github.com/emersion/go-ical"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"nxcaldav/internal/config" "nxcaldav/internal/config"
@@ -33,8 +37,11 @@ type publicInfo struct {
type DBBackend struct { type DBBackend struct {
pool *pgxpool.Pool // Connection pool to PostgreSQL pool *pgxpool.Pool // Connection pool to PostgreSQL
prefix string // Public URL base path prefix 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]") redactionText string // Text used to hide confidential event details (e.g. "[REDACED]")
defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL") 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 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 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 publicAccess map[string]publicInfo // In-memory map of public path -> internal info
@@ -53,8 +60,11 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
b := &DBBackend{ b := &DBBackend{
pool: pool, pool: pool,
prefix: cfg.Server.BasePath(), prefix: cfg.Server.BasePath(),
publicURL: cfg.Server.PublicURL,
redactionText: cfg.Server.Redaction, redactionText: cfg.Server.Redaction,
defaultClass: cfg.Server.DefaultClass, defaultClass: cfg.Server.DefaultClass,
emailDomain: cfg.Server.EmailDomain,
smtp: cfg.SMTP,
aggregates: make(map[string]*config.Aggregate), aggregates: make(map[string]*config.Aggregate),
userAggs: make(map[string][]string), userAggs: make(map[string][]string),
publicAccess: make(map[string]publicInfo), publicAccess: make(map[string]publicInfo),
@@ -88,7 +98,8 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE, owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
path TEXT UNIQUE NOT NULL, path TEXT UNIQUE NOT NULL,
name TEXT, name TEXT,
description TEXT description TEXT,
color TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS calendar_access ( `CREATE TABLE IF NOT EXISTS calendar_access (
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
@@ -97,15 +108,35 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
PRIMARY KEY (calendar_id, user_id) PRIMARY KEY (calendar_id, user_id)
)`, )`,
`CREATE TABLE IF NOT EXISTS calendar_objects ( `CREATE TABLE IF NOT EXISTS calendar_objects (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
path TEXT NOT NULL, path TEXT NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL,
etag TEXT NOT NULL, etag TEXT NOT NULL,
UNIQUE (calendar_id, path) UNIQUE (calendar_id, path)
)`, )`,
} `CREATE TABLE IF NOT EXISTS addressbooks (
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 addressbook_access (
addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL,
PRIMARY KEY (addressbook_id, user_id)
)`,
`CREATE TABLE IF NOT EXISTS addressbook_objects (
id SERIAL PRIMARY KEY,
addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE,
path TEXT NOT NULL,
data TEXT NOT NULL,
etag TEXT NOT NULL,
UNIQUE (addressbook_id, path)
)`,
}
for _, q := range queries { for _, q := range queries {
if _, err := b.pool.Exec(ctx, q); err != nil { if _, err := b.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("failed to execute schema query: %v", err) return fmt.Errorf("failed to execute schema query: %v", err)
@@ -200,10 +231,10 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
var calID int var calID int
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO calendars (owner_id, path, name) VALUES ($1, $2, $3) 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 ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color
RETURNING id`, RETURNING id`,
ownerID, path, c.ID).Scan(&calID) ownerID, path, c.ID, c.Color).Scan(&calID)
if err != nil { if err != nil {
return err return err
} }
@@ -256,6 +287,65 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
} }
} }
// --- Phase 2.5: Address Book & Access Sync ---
configAddressBookPaths := make(map[string]bool)
for _, ab := range cfg.AddressBooks {
path := prefix + fmt.Sprintf("/%s/addressbooks/%s/", ab.Owner, ab.ID)
configAddressBookPaths[path] = true
var ownerID int
err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", ab.Owner).Scan(&ownerID)
if err != nil {
return fmt.Errorf("owner %s not found: %v", ab.Owner, err)
}
var abID int
err = tx.QueryRow(ctx, `
INSERT INTO addressbooks (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, ab.ID).Scan(&abID)
if err != nil {
return err
}
// Re-build access rules for this address book
_, err = tx.Exec(ctx, "DELETE FROM addressbook_access WHERE addressbook_id = $1", abID)
if err != nil {
return err
}
addressBookAccessModes := make(map[string]string)
for _, a := range ab.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 {
addressBookAccessModes[u] = a.Mode
}
}
for uName, mode := range addressBookAccessModes {
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 addressbook_access (addressbook_id, user_id, mode) VALUES ($1, $2, $3)",
abID, userID, mode)
if err != nil {
return err
}
}
}
// --- Phase 3: Aggregate Setup --- // --- Phase 3: Aggregate Setup ---
// Aggregates are virtual, so we only track them in memory for routing. // Aggregates are virtual, so we only track them in memory for routing.
for _, agg := range cfg.Aggregates { for _, agg := range cfg.Aggregates {
@@ -359,12 +449,12 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
// --- Internal Security & Privacy Helpers --- // --- Internal Security & Privacy Helpers ---
// checkAccess verifies if the current user has the required permission for a real calendar. // 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 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. // 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) { func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return 0, err return 0, "", err
} }
if !strings.HasSuffix(calendarPath, "/") { if !strings.HasSuffix(calendarPath, "/") {
@@ -380,14 +470,14 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName) WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return 0, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
} }
return 0, err return 0, "", err
} }
// Owners always have full access // Owners always have full access
if ownerName == username { if ownerName == username {
return calID, nil return calID, "owner", nil
} }
// Check the calendar_access table for specific permissions // Check the calendar_access table for specific permissions
@@ -398,34 +488,35 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
calID, username).Scan(&mode) calID, username).Scan(&mode)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied")) return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
} }
return 0, err return 0, "", err
} }
// Enforce Read-Only mode // Enforce Read-Only mode
if requiredMode == "write" && mode != "read-write" { if requiredMode == "write" && mode != "read-write" {
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
} }
return calID, nil return calID, mode, nil
} }
// filterCalendar is the privacy enforcement engine of the server. // filterCalendar is the privacy enforcement engine of the server.
// It is called every time a calendar object is retrieved from the database // It is called every time a calendar object is retrieved from the database
// for a user who is NOT the owner. // 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: // It applies the following rules based on the 'CLASS' property of an event/task:
// 1. PRIVATE: Removes the object entirely. // 1. PRIVATE: Removes the object entirely.
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data. // 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
// 3. PUBLIC: Passes the object through unchanged. // 3. PUBLIC: Passes the object through unchanged.
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, original *ical.Calendar) (*ical.Calendar, error) { func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Owners see everything unredacted // Owners and users with read-write access see everything unredacted.
if username == ownerName { // We check both username and mode for robustness.
if username == ownerName || mode == "owner" || mode == "read-write" {
return original, nil return original, nil
} }
@@ -499,7 +590,7 @@ func (b *DBBackend) ServePublicICS(w http.ResponseWriter, r *http.Request) {
JOIN users u ON c.owner_id = u.id JOIN users u ON c.owner_id = u.id
WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName) WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
if err == nil { if err == nil {
objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName) objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName, "read")
} }
} }
@@ -639,7 +730,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
} }
username, _ := b.getUsername(ctx) username, _ := b.getUsername(ctx)
log.Printf("[ListCalendarObjects] user=%s path=%s", username, p) log.Printf("[user: %s] ListCalendarObjects %s", username, p)
// Route to virtual aggregate logic if needed // Route to virtual aggregate logic if needed
if agg, ok := b.aggregates[p]; ok { if agg, ok := b.aggregates[p]; ok {
@@ -652,7 +743,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
} }
// Normal calendar logic // Normal calendar logic
calID, err := b.checkAccess(ctx, p, "read") calID, mode, err := b.checkAccess(ctx, p, "read")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -663,10 +754,10 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
return nil, err return nil, err
} }
return b.listCalendarObjectsRaw(ctx, calID, ownerName) return b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
} }
func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string) ([]caldav.CalendarObject, error) { 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) rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -687,7 +778,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
} }
// Apply privacy filters (Private/Confidential) // Apply privacy filters (Private/Confidential)
filteredData, err := b.filterCalendar(ctx, ownerName, calData) filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
if err != nil || filteredData == nil { if err != nil || filteredData == nil {
continue continue
} }
@@ -701,6 +792,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
// It rewrites paths so the client thinks items are in the virtual directory. // 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) { func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
var res []caldav.CalendarObject var res []caldav.CalendarObject
username, _ := b.getUsername(ctx)
for _, sourceID := range agg.Sources { for _, sourceID := range agg.Sources {
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID) sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
@@ -715,7 +807,21 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
continue continue
} }
objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName) // 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 { if err != nil {
continue continue
} }
@@ -746,7 +852,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
// Called during GET, multiget REPORT, or when clicking an event in the client. // 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) { func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx) username, _ := b.getUsername(ctx)
log.Printf("[GetCalendarObject] user=%s path=%s", username, p) log.Printf("[user: %s] GetCalendarObject %s", username, p)
dirPath := path.Dir(p) + "/" dirPath := path.Dir(p) + "/"
fileName := path.Base(p) fileName := path.Base(p)
@@ -788,7 +894,21 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
return nil, err return nil, err
} }
filteredData, err := b.filterCalendar(ctx, ownerName, calData) // 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 { if err != nil || filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
} }
@@ -814,7 +934,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
dirPath += "/" dirPath += "/"
} }
calID, err := b.checkAccess(ctx, dirPath, "read") calID, mode, err := b.checkAccess(ctx, dirPath, "read")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -841,7 +961,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
} }
// Apply privacy filtering // Apply privacy filtering
filteredData, err := b.filterCalendar(ctx, ownerName, calData) filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
if err != nil || filteredData == nil { if err != nil || filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
} }
@@ -859,7 +979,7 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only")) return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
} }
calID, err := b.checkAccess(ctx, dirPath, "write") calID, _, err := b.checkAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -880,6 +1000,88 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i
return nil, err 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{ return &caldav.CalendarObject{
Path: p, Path: p,
Data: calendar, Data: calendar,
@@ -895,7 +1097,7 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only")) return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
} }
calID, err := b.checkAccess(ctx, dirPath, "write") calID, _, err := b.checkAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return err return err
} }
@@ -910,11 +1112,383 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
return nil 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). // QueryCalendarObjects filters items based on a CalDAV query (e.g. time range).
// Currently, it just lists all objects and lets the client filter, but // Currently, it just lists all objects and lets the client filter, but
// we use it to enforce privacy rules for the initial report. // 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) { func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx) username, _ := b.getUsername(ctx)
log.Printf("[QueryCalendarObjects] user=%s path=%s", username, path_) log.Printf("[user: %s] QueryCalendarObjects %s", username, path_)
return b.ListCalendarObjects(ctx, path_, nil) 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 ""
}
// --- CardDAV Backend Implementation ---
func (b *DBBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
username, err := b.getUsername(ctx)
if err != nil {
return "", err
}
return b.prefix + fmt.Sprintf("/%s/addressbooks/", username), nil
}
func (b *DBBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
username, err := b.getUsername(ctx)
if err != nil {
return nil, err
}
rows, err := b.pool.Query(ctx, `
SELECT path, name, COALESCE(description, '') FROM addressbooks
WHERE owner_id = (SELECT id FROM users WHERE name = $1)
OR id IN (SELECT addressbook_id FROM addressbook_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`,
username)
if err != nil {
return nil, err
}
defer rows.Close()
var res []carddav.AddressBook
for rows.Next() {
var ab carddav.AddressBook
if err := rows.Scan(&ab.Path, &ab.Name, &ab.Description); err != nil {
return nil, err
}
ab.MaxResourceSize = 1000000
ab.SupportedAddressData = []carddav.AddressDataType{
{ContentType: "text/vcard", Version: "3.0"},
{ContentType: "text/vcard", Version: "4.0"},
}
res = append(res, ab)
}
return res, nil
}
func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) {
if !strings.HasSuffix(p, "/") {
p += "/"
}
var ab carddav.AddressBook
err := b.pool.QueryRow(ctx, "SELECT path, name, COALESCE(description, '') FROM addressbooks WHERE path = $1", p).Scan(&ab.Path, &ab.Name, &ab.Description)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
}
return nil, err
}
ab.MaxResourceSize = 1000000
ab.SupportedAddressData = []carddav.AddressDataType{
{ContentType: "text/vcard", Version: "3.0"},
{ContentType: "text/vcard", Version: "4.0"},
}
return &ab, nil
}
func (b *DBBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book creation only via config"))
}
func (b *DBBackend) DeleteAddressBook(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book deletion only via config"))
}
func (b *DBBackend) checkAddressBookAccess(ctx context.Context, abPath string, requiredMode string) (int, string, error) {
username, err := b.getUsername(ctx)
if err != nil {
return 0, "", err
}
if !strings.HasSuffix(abPath, "/") {
abPath += "/"
}
var abID int
var ownerName string
err = b.pool.QueryRow(ctx, `
SELECT a.id, u.name
FROM addressbooks a
JOIN users u ON a.owner_id = u.id
WHERE a.path = $1`, abPath).Scan(&abID, &ownerName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
}
return 0, "", err
}
if ownerName == username {
return abID, "owner", nil
}
var mode string
err = b.pool.QueryRow(ctx, `
SELECT mode FROM addressbook_access
WHERE addressbook_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
abID, 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 abID, mode, nil
}
func (b *DBBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
if !strings.HasSuffix(p, "/") {
p += "/"
}
abID, _, err := b.checkAddressBookAccess(ctx, p, "read")
if err != nil {
return nil, err
}
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1", abID)
if err != nil {
return nil, err
}
defer rows.Close()
var res []carddav.AddressObject
for rows.Next() {
var obj carddav.AddressObject
var dataStr string
if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
return nil, err
}
card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil {
continue
}
obj.Card = card
res = append(res, obj)
}
return res, nil
}
func (b *DBBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "read")
if err != nil {
return nil, err
}
var obj carddav.AddressObject
var dataStr string
err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p).Scan(&obj.Path, &dataStr, &obj.ETag)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
}
return nil, err
}
card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil {
return nil, err
}
obj.Card = card
return &obj, nil
}
func (b *DBBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := vcard.NewEncoder(&buf).Encode(card); err != nil {
return nil, err
}
dataStr := buf.String()
etag := fmt.Sprintf(`"%d"`, len(dataStr))
_, err = b.pool.Exec(ctx, `
INSERT INTO addressbook_objects (addressbook_id, path, data, etag) VALUES ($1, $2, $3, $4)
ON CONFLICT (addressbook_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
abID, p, dataStr, etag)
if err != nil {
return nil, err
}
return &carddav.AddressObject{
Path: p,
Card: card,
ETag: etag,
}, nil
}
func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error {
dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
if err != nil {
return err
}
commandTag, err := b.pool.Exec(ctx, "DELETE FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p)
if err != nil {
return err
}
if commandTag.RowsAffected() == 0 {
return webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
}
return nil
}
func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
return b.ListAddressObjects(ctx, p, nil)
}

119
internal/backend/email.go Normal file
View File

@@ -0,0 +1,119 @@
package backend
import (
"bytes"
"crypto/tls"
"fmt"
"log"
"net/smtp"
"net/url"
"strings"
"github.com/emersion/go-ical"
)
// sendInvitation sends an iMIP (RFC 6047) invitation email.
// It includes a plain-text fallback and a METHOD:REQUEST iCalendar attachment
// that calendar clients (Thunderbird, Apple, etc.) will recognize.
func (b *DBBackend) sendInvitation(senderName, recipientEmail, summary, description, start, end, objectPath, originalICS string) error {
fromAddr := fmt.Sprintf("%s@%s", senderName, b.emailDomain)
if b.smtp.User != "" {
fromAddr = b.smtp.User
}
fromHeader := fmt.Sprintf("%s <%s>", senderName, fromAddr)
baseURL := strings.TrimSuffix(b.publicURL, "/")
acceptURL := fmt.Sprintf("%s/respond?path=%s&attendee=%s&status=ACCEPTED", baseURL, url.QueryEscape(objectPath), url.QueryEscape(recipientEmail))
declineURL := fmt.Sprintf("%s/respond?path=%s&attendee=%s&status=DECLINED", baseURL, url.QueryEscape(objectPath), url.QueryEscape(recipientEmail))
// 1. Prepare plain-text fallback - with prominent links
textPart := "PLEASE RESPOND TO THIS INVITATION:\r\n"
textPart += fmt.Sprintf("✅ ACCEPT: %s\r\n", acceptURL)
textPart += fmt.Sprintf("❌ DECLINE: %s\r\n", declineURL)
textPart += "\r\n------------------------------------------\r\n\r\n"
textPart += fmt.Sprintf("You have been invited to an event by %s.\r\n\r\n", senderName)
textPart += fmt.Sprintf("Event: %s\r\n", summary)
// 2. Prepare iCalendar part with METHOD:REQUEST
var icsContent string
calendar, err := ical.NewDecoder(strings.NewReader(originalICS)).Decode()
if err == nil {
calendar.Props.SetText("METHOD", "REQUEST")
// Discourage clients from sending their own response emails
for _, event := range calendar.Events() {
for _, attendee := range event.Props["ATTENDEE"] {
attendee.Params.Set("RSVP", "FALSE")
}
}
var buf bytes.Buffer
if err := ical.NewEncoder(&buf).Encode(calendar); err == nil {
icsContent = buf.String()
}
}
if icsContent == "" {
icsContent = originalICS // Fallback to raw if decoding failed
}
// 3. Construct Multipart MIME Email
boundary := "nxcaldav_invite_boundary"
subject := fmt.Sprintf("Invitation: %s", summary)
header := fmt.Sprintf("Subject: %s\r\n", subject)
header += fmt.Sprintf("From: %s\r\n", fromHeader)
header += fmt.Sprintf("To: %s\r\n", recipientEmail)
header += "MIME-Version: 1.0\r\n"
header += fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", boundary)
header += "\r\n"
body := fmt.Sprintf("--%s\r\n", boundary)
body += "Content-Type: text/plain; charset=UTF-8\r\n"
body += "Content-Transfer-Encoding: 7bit\r\n"
body += "\r\n"
body += textPart + "\r\n"
body += fmt.Sprintf("--%s\r\n", boundary)
body += "Content-Type: text/calendar; method=REQUEST; charset=UTF-8\r\n"
body += "Content-Transfer-Encoding: 7bit\r\n"
body += "\r\n"
body += icsContent + "\r\n"
body += fmt.Sprintf("--%s--\r\n", boundary)
// 4. Send the mail
addr := fmt.Sprintf("%s:%d", b.smtp.Host, b.smtp.Port)
tlsConfig := &tls.Config{InsecureSkipVerify: true, ServerName: b.smtp.Host}
var c *smtp.Client
if b.smtp.Port == 465 {
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil { return err }
c, err = smtp.NewClient(conn, b.smtp.Host)
} else {
c, err = smtp.Dial(addr)
}
if err != nil { return err }
defer c.Close()
if err = c.Hello("localhost"); err != nil { return err }
if b.smtp.Port != 465 {
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(tlsConfig); err != nil { return err }
}
}
if b.smtp.User != "" && b.smtp.Password != "" {
auth := smtp.PlainAuth("", b.smtp.User, b.smtp.Password, b.smtp.Host)
if err = c.Auth(auth); err != nil { return err }
}
if err = c.Mail(fromAddr); err != nil { return err }
if err = c.Rcpt(recipientEmail); err != nil { return err }
w, err := c.Data()
if err != nil { return err }
_, err = w.Write([]byte(header + body))
if err != nil { return err }
err = w.Close()
if err != nil { return err }
log.Printf("[email] Successfully sent iMIP invitation to %s", recipientEmail)
return c.Quit()
}

View File

@@ -18,14 +18,23 @@ type Access struct {
} }
type Calendar struct { type Calendar struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Access []Access `yaml:"access,omitempty"` Color string `yaml:"color,omitempty"`
Access []Access `yaml:"access,omitempty"`
}
type AddressBook struct {
ID string `yaml:"id"`
Owner string `yaml:"owner"`
Access []Access `yaml:"access,omitempty"`
} }
type Aggregate struct { type Aggregate struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Color string `yaml:"color,omitempty"`
Sources []string `yaml:"sources"` // Calendar IDs Sources []string `yaml:"sources"` // Calendar IDs
Access []Access `yaml:"access,omitempty"` Access []Access `yaml:"access,omitempty"`
} }
@@ -44,6 +53,7 @@ type DatabaseConfig struct {
type ServerConfig struct { type ServerConfig struct {
BindAddress string `yaml:"bind_address"` BindAddress string `yaml:"bind_address"`
PublicURL string `yaml:"public_url"` PublicURL string `yaml:"public_url"`
EmailDomain string `yaml:"email_domain"`
Redaction string `yaml:"redaction_text"` Redaction string `yaml:"redaction_text"`
DefaultClass string `yaml:"default_class"` DefaultClass string `yaml:"default_class"`
} }
@@ -56,14 +66,22 @@ func (s ServerConfig) BasePath() string {
return strings.TrimSuffix(u.Path, "/") return strings.TrimSuffix(u.Path, "/")
} }
type Config struct { type SMTPConfig struct {
Server ServerConfig `yaml:"server"` Host string `yaml:"host"`
Database DatabaseConfig `yaml:"database"` Port int `yaml:"port"`
Users []User `yaml:"users"` User string `yaml:"user"`
Calendars []Calendar `yaml:"calendars"` Password string `yaml:"password"`
Aggregates []Aggregate `yaml:"aggregates"`
} }
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
SMTP SMTPConfig `yaml:"smtp"`
Users []User `yaml:"users"`
Calendars []Calendar `yaml:"calendars"`
AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"`
}
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
@@ -98,7 +116,16 @@ func (c *Config) setDefaults() {
if c.Server.DefaultClass == "" { if c.Server.DefaultClass == "" {
c.Server.DefaultClass = "CONFIDENTIAL" c.Server.DefaultClass = "CONFIDENTIAL"
} }
if c.Server.EmailDomain == "" {
c.Server.EmailDomain = "nx2.site"
}
if c.Database.URL == "" { if c.Database.URL == "" {
c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
} }
if c.SMTP.Host == "" {
c.SMTP.Host = "localhost"
}
if c.SMTP.Port == 0 {
c.SMTP.Port = 25
}
} }

188
internal/extra/color.go Normal file
View File

@@ -0,0 +1,188 @@
package extra
import (
"bytes"
"strings"
"fmt"
"context"
"io"
"log"
"net/http"
"nxcaldav/internal/backend"
"regexp"
"time"
)
type responseWriter struct {
http.ResponseWriter
buffer *bytes.Buffer
status int
}
func (rw *responseWriter) Write(b []byte) (int, error) {
return rw.buffer.Write(b)
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
}
func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
reqBody, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
buf := &bytes.Buffer{}
rw := &responseWriter{w, buf, http.StatusOK}
handler.ServeHTTP(rw, r.WithContext(ctx))
body := buf.Bytes()
// 1. Add namespaces to the root multistatus tag
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:ICAL="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/"`))
// 2. Response processing
reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?</[a-zA-Z0-9]*:?response>`)
reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)</[a-zA-Z0-9]*:?href>`)
rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?</[a-zA-Z0-9]*:?propstat>`)
reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK</[a-zA-Z0-9]*:?status>`)
reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?</[a-zA-Z0-9]*:?prop>`)
rePropClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?prop>`)
body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte {
hrefMatch := reHref.FindSubmatch(resp)
if len(hrefMatch) < 2 {
return resp
}
href := string(hrefMatch[1])
color := be.GetColor(r.Context(), href)
if color == "" {
return resp
}
// 1. Strip any existing conflicting tags that might be in 404 blocks
reStrip := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?/>|<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?>.*?</[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order)>`)
resp = reStrip.ReplaceAll(resp, []byte(""))
fullColor := strings.ToLower(color)
if len(fullColor) == 7 && strings.HasPrefix(fullColor, "#") {
fullColor += "ff"
}
// Prepare the properties to inject
props := fmt.Sprintf("<ICAL:calendar-color>%s</ICAL:calendar-color>", fullColor)
props += fmt.Sprintf("<C:calendar-color>%s</C:calendar-color>", fullColor)
props += "<ICAL:calendar-order>0</ICAL:calendar-order>"
props += fmt.Sprintf("<CS:getctag>\"%d\"</CS:getctag>", time.Now().Unix())
// 2. Try to inject into an existing 200 OK propstat
has200 := false
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
if reStatusOk.Match(ps) {
has200 = true
return reProp.ReplaceAllFunc(ps, func(prop []byte) []byte {
return rePropClose.ReplaceAllFunc(prop, func(closeTag []byte) []byte {
return append([]byte(props), closeTag...)
})
})
}
return ps
})
// 3. If no 200 OK propstat was found, create one!
if !has200 {
newPropstat := fmt.Sprintf("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", props)
reResponseClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?response>`)
resp = reResponseClose.ReplaceAllFunc(resp, func(closeTag []byte) []byte {
return append([]byte(newPropstat), closeTag...)
})
}
return resp
})
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(rw.status)
w.Write(body)
}
func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
reqBody, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
buf := &bytes.Buffer{}
rw := &responseWriter{w, buf, http.StatusOK}
handler.ServeHTTP(rw, r.WithContext(ctx))
body := buf.Bytes()
// 1. Unconditionally add namespaces to root tag
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav"`))
// 2. Response processing
reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?</[a-zA-Z0-9]*:?response>`)
reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)</[a-zA-Z0-9]*:?href>`)
rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?</[a-zA-Z0-9]*:?propstat>`)
reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK</[a-zA-Z0-9]*:?status>`)
reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?</[a-zA-Z0-9]*:?prop>`)
rePropClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?prop>`)
body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte {
hrefMatch := reHref.FindSubmatch(resp)
if len(hrefMatch) < 2 {
return resp
}
calHome, _ := be.CalendarHomeSetPath(ctx)
cardHome, _ := be.AddressBookHomeSetPath(ctx)
// Strip these tags ONLY from non-200 propstats
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
if !reStatusOk.Match(ps) {
reTags := regexp.MustCompile(`(?s)<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?/>|<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?>.*?</[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set)>`)
return reTags.ReplaceAll(ps, []byte(""))
}
return ps
})
props := ""
if calHome != "" && !strings.Contains(string(resp), "calendar-home-set") {
props += fmt.Sprintf("<C:calendar-home-set><href xmlns=\"DAV:\">%s</href></C:calendar-home-set>", calHome)
}
if cardHome != "" && !strings.Contains(string(resp), "addressbook-home-set") {
props += fmt.Sprintf("<card:addressbook-home-set><href xmlns=\"DAV:\">%s</href></card:addressbook-home-set>", cardHome)
}
if props == "" {
return resp
}
has200 := false
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
if reStatusOk.Match(ps) {
has200 = true
return reProp.ReplaceAllFunc(ps, func(prop []byte) []byte {
return rePropClose.ReplaceAllFunc(prop, func(closeTag []byte) []byte {
return append([]byte(props), closeTag...)
})
})
}
return ps
})
if !has200 {
newPropstat := fmt.Sprintf("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", props)
reResponseClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?response>`)
resp = reResponseClose.ReplaceAllFunc(resp, func(closeTag []byte) []byte {
return append([]byte(newPropstat), closeTag...)
})
}
return resp
})
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(rw.status)
w.Write(body)
}

128
main.go
View File

@@ -1,28 +1,34 @@
package main package main
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os" "os"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav"
"nxcaldav/internal/backend" "nxcaldav/internal/backend"
"nxcaldav/internal/extra"
"nxcaldav/internal/config" "nxcaldav/internal/config"
) )
func main() { func main() {
path := "config.yaml"; path := "config.yaml";
if len(os.Args) != 0 { if len(os.Args) == 3 {
if os.Args[1] == "-c" { if os.Args[1] == "-c" {
path = os.Args[2] path = os.Args[2]
} }
} }
cfg, err := config.Load() cfg, err := config.Load(path)
if err != nil { if err != nil {
log.Fatalf("failed to load config: %v", err) log.Fatalf("failed to load config: %v", err)
} }
@@ -33,10 +39,31 @@ func main() {
log.Fatalf("failed to initialize database backend: %v", err) log.Fatalf("failed to initialize database backend: %v", err)
} }
handler := &caldav.Handler{Backend: be} caldavHandler := &caldav.Handler{Backend: be}
carddavHandler := &carddav.Handler{Backend: be}
publicURL, _ := url.Parse(cfg.Server.PublicURL) publicURL, _ := url.Parse(cfg.Server.PublicURL)
http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Query().Get("path")
attendee := r.URL.Query().Get("attendee")
status := r.URL.Query().Get("status")
if p == "" || attendee == "" || status == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
err := be.RespondToInvitation(r.Context(), p, attendee, status)
if err != nil {
log.Printf("[email] Error handling response: %v", err)
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<h1>Response recorded</h1><p>You have %s the invitation for %s.</p>", strings.ToLower(status), attendee)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Proxy-aware normalization: // Proxy-aware normalization:
if publicURL != nil && publicURL.Host != "" { if publicURL != nil && publicURL.Host != "" {
@@ -61,12 +88,14 @@ func main() {
} }
} }
// public ics access
prefix := cfg.Server.BasePath() prefix := cfg.Server.BasePath()
if strings.HasPrefix(r.URL.Path, prefix+"/public/") { if strings.HasPrefix(r.URL.Path, prefix+"/public/") {
be.ServePublicICS(w, r) be.ServePublicICS(w, r)
return return
} }
// caldav access needs auth
user, password, ok := r.BasicAuth() user, password, ok := r.BasicAuth()
if !ok { if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`) w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`)
@@ -88,31 +117,72 @@ func main() {
return return
} }
log.Printf("%s %s (user: %s)", r.Method, r.URL.Path, user) log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path)
prefix = cfg.Server.BasePath()
principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath)
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" { principalPath := prefix + fmt.Sprintf("/%s/", user)
// If we normalized the request, use the normalized host/scheme for the redirect
if publicURL != nil && publicURL.Host != "" {
scheme := r.URL.Scheme
if scheme == "" {
scheme = "http"
}
target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath)
http.Redirect(w, r, target, http.StatusMovedPermanently)
} else {
http.Redirect(w, r, principalPath, http.StatusMovedPermanently)
}
return
}
// Add addressbook to DAV header for discovery/access paths
// This includes principal path and .well-known
isCardDAVPath := strings.Contains(r.URL.Path, "/addressbooks/") ||
r.URL.Path == "/.well-known/carddav" ||
r.URL.Path == prefix+"/.well-known/carddav" ||
r.URL.Path == principalPath ||
r.URL.Path == strings.TrimSuffix(principalPath, "/")
if isCardDAVPath {
w.Header().Add("DAV", "addressbook")
}
handler.ServeHTTP(w, r.WithContext(ctx)) ctx := context.WithValue(r.Context(), "principal", principalPath)
})
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress) if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" ||
r.URL.Path == "/.well-known/carddav" || r.URL.Path == prefix+"/.well-known/carddav" {
// If normalized request, use normalized host/scheme for redirect
if publicURL != nil && publicURL.Host != "" {
scheme := r.URL.Scheme
if scheme == "" {
scheme = "http"
}
target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath)
http.Redirect(w, r, target, http.StatusMovedPermanently)
} else {
http.Redirect(w, r, principalPath, http.StatusMovedPermanently)
}
return
}
// needed because color info is not RFC, so regex hack
if strings.Contains(r.URL.Path, "/calendars/") {
if r.Method == "PROPFIND" {
extra.AddColorToCalendarPropfind(r, ctx, caldavHandler, w, be)
} else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx))
}
} else if strings.Contains(r.URL.Path, "/addressbooks/") {
carddavHandler.ServeHTTP(w, r.WithContext(ctx))
} else {
// Fallback: try both or default to caldav for principal path etc.
if strings.HasSuffix(r.URL.Path, user+"/") || strings.HasSuffix(r.URL.Path, user) {
// For principal path, use merged discovery handler
if r.Method == "PROPFIND" {
extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be)
} else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx))
// Ensure DAV header includes addressbook for OPTIONS
if r.Method == "OPTIONS" {
dav := w.Header().Get("DAV")
if dav != "" && !strings.Contains(dav, "addressbook") {
w.Header().Set("DAV", dav+", addressbook")
}
}
}
} else {
http.NotFound(w, r)
}
}
})
fmt.Printf("Starting CalDAV/CardDAV server on %s...\n", cfg.Server.BindAddress)
server := &http.Server{ server := &http.Server{
Addr: cfg.Server.BindAddress, Addr: cfg.Server.BindAddress,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,