8 Commits

Author SHA1 Message Date
ce6a5c7477 Delete shell.nix 2026-04-23 17:21:10 +02:00
Lennart J. Kurzweg (Nx2)
f66f58f67f no shebang 2026-04-23 17:17:52 +02:00
Lennart J. Kurzweg (Nx2)
5f036dbc89 fixed frfr 2026-04-22 20:32:48 +02:00
Lennart J. Kurzweg (Nx2)
6394c8496d remove log 2026-04-22 17:46:23 +02:00
Lennart J. Kurzweg (Nx2)
579ba8f5ef cleanups 2026-04-22 17:30:07 +02:00
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
15 changed files with 1393 additions and 183 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
server.log server.log
mem.go mem.go
nxcaldav nxcaldav
in/
out/

View File

@@ -1,4 +1,6 @@
.direnv .direnv
server.log # server.log
mem.go mem.go
nxcaldav nxcaldav
in/
out/

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
@@ -33,3 +33,56 @@ UPDATE calendar_objects SET path = regexp_replace(path, '/old/', '/new/')' WHERE
```sql ```sql
select id, path, linecut from calendar_objects, LATERAL regexp_split_to_table(data, E'\r\n') AS linecut where linecut ~ 'CLASS'; select id, path, linecut from calendar_objects, LATERAL regexp_split_to_table(data, E'\r\n') AS linecut where linecut ~ 'CLASS';
``` ```
---
# Helpers
## Prerequisites
The scripts require psycopg2 (or psycopg2-binary) and PyYAML.
```shell
pip install psycopg2-binary pyyaml
```
## Exporting Events
The `export_events.py` script extracts events from the database and saves them as `.ics` files in a structured directory format: `username/calendarID/filename.ics`.
### Examples:
- Export everything:
```shell
python export_events.py --output ./my_backup
```
- Export only one user:
```shell
python export_events.py --user alice --output ./alice_backup
```
- Export a specific calendar:
```shell
python export_events.py --user alice --calendar preservation --output ./preservation_backup
```
## Importing Events
The `import_events.py` script uploads events back into the database. It can either walk a structured directory (like the one created by the export script) or upload a flat directory of files to a specific target calendar.
### Examples:
- Restore everything from a backup:
```shell
python import_events.py --input ./my_backup
```
- Upload a directory of .ics files to a specific calendar:
```shell
python import_events.py --input ./some_events/ --user alice --calendar work
```
- Upload a single file to a specific calendar:
```shell
python import_events.py --input ./event.ics --user bob --calendar bob
```

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

76
export_events.py Normal file
View File

@@ -0,0 +1,76 @@
import os
import argparse
import psycopg2
import yaml
from urllib.parse import urlparse
def get_db_url(config_path):
with open(config_path, 'r') as f:
cfg = yaml.safe_load(f)
return cfg.get('database', {}).get('url')
def export_events():
parser = argparse.ArgumentParser(description='Export CalDAV events from database to files.')
parser.add_argument('--config', default='config.yaml', help='Path to config.yaml')
parser.add_argument('--output', default='export', help='Output directory')
parser.add_argument('--user', help='Filter by user name')
parser.add_argument('--calendar', help='Filter by calendar ID (name in DB)')
args = parser.parse_args()
db_url = get_db_url(args.config)
if not db_url:
print("Error: Could not find database URL in config.")
return
try:
conn = psycopg2.connect(db_url)
cur = conn.cursor()
except Exception as e:
print(f"Error connecting to database: {e}")
return
query = """
SELECT u.name as user_name, c.name as cal_name, co.path, co.data
FROM calendar_objects co
JOIN calendars c ON co.calendar_id = c.id
JOIN users u ON c.owner_id = u.id
WHERE 1=1
"""
params = []
if args.user:
query += " AND u.name = %s"
params.append(args.user)
if args.calendar:
query += " AND c.name = %s"
params.append(args.calendar)
cur.execute(query, params)
rows = cur.fetchall()
if not rows:
print("No events found matching the filters.")
return
for user_name, cal_name, obj_path, data in rows:
# Create directory structure: output/user/calendar/
target_dir = os.path.join(args.output, user_name, cal_name)
os.makedirs(target_dir, exist_ok=True)
# Filename from the path (the last part after /)
filename = os.path.basename(obj_path)
if not filename: # Should not happen with valid paths
continue
file_path = os.path.join(target_dir, filename)
with open(file_path, 'w') as f:
f.write(data)
print(f"Exported: {user_name}/{cal_name}/{filename}")
cur.close()
conn.close()
print(f"Done. Exported {len(rows)} events to '{args.output}'.")
if __name__ == "__main__":
export_events()

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=

123
import_events.py Normal file
View File

@@ -0,0 +1,123 @@
import os
import argparse
import psycopg2
import yaml
from urllib.parse import urlparse
def get_config(config_path):
with open(config_path, 'r') as f:
return yaml.safe_load(f)
def count_events(data):
return data.count('BEGIN:VEVENT')
def import_events():
parser = argparse.ArgumentParser(description='Import CalDAV events from files to database.')
parser.add_argument('--config', default='config.yaml', help='Path to config.yaml')
parser.add_argument('--input', default='export', help='Input directory or file')
parser.add_argument('--user', help='Target user (filter or override)')
parser.add_argument('--calendar', help='Target calendar (filter or override)')
args = parser.parse_args()
cfg = get_config(args.config)
db_url = cfg.get('database', {}).get('url')
if not db_url:
print("Error: Could not find database URL in config.")
return
try:
conn = psycopg2.connect(db_url)
cur = conn.cursor()
except Exception as e:
print(f"Error connecting to database: {e}")
return
def upload_file(file_path, user_name, cal_name):
cur.execute("""
SELECT c.id, c.path
FROM calendars c
JOIN users u ON c.owner_id = u.id
WHERE u.name = %s AND c.name = %s
""", (user_name, cal_name))
res = cur.fetchone()
if not res:
print(f"Warning: Calendar '{cal_name}' for user '{user_name}' not found in DB. Skipping {file_path}")
return False
cal_id, cal_base_path = res
with open(file_path, 'r') as f:
data = f.read()
filename = os.path.basename(file_path)
obj_path = os.path.join(cal_base_path, filename)
etag = f'"{count_events(data)}"'
cur.execute("""
INSERT INTO calendar_objects (calendar_id, path, data, etag)
VALUES (%s, %s, %s, %s)
ON CONFLICT (calendar_id, path) DO UPDATE
SET data = EXCLUDED.data, etag = EXCLUDED.etag
""", (cal_id, obj_path, data, etag))
return True
# Gather all files
files_to_process = []
if os.path.isfile(args.input):
files_to_process.append(args.input)
elif os.path.isdir(args.input):
for root, _, filenames in os.walk(args.input):
for f in filenames:
if f.endswith('.ics'):
files_to_process.append(os.path.join(root, f))
else:
print(f"Error: Input '{args.input}' not found.")
return
success_count = 0
for f_path in files_to_process:
# Determine source structure
# If input is a dir, rel_path is relative to it. If file, it's just the filename.
if os.path.isdir(args.input):
rel_path = os.path.relpath(f_path, args.input)
else:
rel_path = os.path.basename(f_path)
parts = rel_path.split(os.sep)
source_user = None
source_cal = None
# Structure: user/calendar/file.ics (len 3)
if len(parts) >= 3:
source_user = parts[-3]
source_cal = parts[-2]
# Structure: calendar/file.ics (len 2)
elif len(parts) == 2:
source_cal = parts[-2]
# 1. Apply Filtering: If flag is set and we have a source value, they must match.
if args.user and source_user and args.user != source_user:
continue
if args.calendar and source_cal and args.calendar != source_cal:
continue
# 2. Determine Target: Flag overrides source.
target_user = args.user or source_user
target_cal = args.calendar or source_cal
if not target_user or not target_cal:
print(f"Skipping {f_path}: Cannot determine user/calendar. Use --user and --calendar flags.")
continue
if upload_file(f_path, target_user, target_cal):
print(f"Imported: {target_user}/{target_cal}/{os.path.basename(f_path)}")
success_count += 1
conn.commit()
cur.close()
conn.close()
print(f"Done. Successfully imported {success_count} events.")
if __name__ == "__main__":
import_events()

View File

@@ -1,49 +1,51 @@
package backend package backend
import ( import (
"slices"
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"os/exec" "os/exec"
"path" "path"
"slices"
"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"
) )
// for ICS access
type publicInfo struct { type publicInfo struct {
InternalPath string InternalPath string
Mode string // e.g., "future-only" Mode string // e.g., "future-only"
} }
// DBBackend implements the caldav.Backend interface using a PostgreSQL database. // DBBackend = caldav.Backend interface (PostgreSQL)
// It manages users, calendars, and their events/tasks with built-in support for
// access control, privacy redaction, and aggregate (virtual) calendars.
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://dav.example.com/)
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")
aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions emailDomain string // Domain for email addresses (e.g., "example.com" if email is tony@example.com)
userAggs map[string][]string // In-memory map of user -> list of aggregate paths they can see smtp config.SMTPConfig
publicAccess map[string]publicInfo // In-memory map of public path -> internal info aggregates map[string]*config.Aggregate // In-memory maps...
userAggs map[string][]string
publicAccess map[string]publicInfo
} }
// NewDBBackend creates a new database-backed CalDAV provider.
// It is called once during main.go startup.
// It initializes the database connection, ensures the tables exist (Schema),
// and synchronizes the config.yaml data into the database tables.
func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) { func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
pool, err := pgxpool.New(ctx, cfg.Database.URL) pool, err := pgxpool.New(ctx, cfg.Database.URL)
if err != nil { if err != nil {
@@ -53,8 +55,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),
@@ -74,8 +79,7 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
} }
// initSchema runs the DDL queries to create the necessary tables if they don't exist. // initSchema runs the DDL queries to create the necessary tables if they don't exist.
// We use ON DELETE CASCADE extensively so that deleting a user or calendar // it uses ON DELETE CASCADE extensively
// automatically wipes all related events and access rules in the DB.
func (b *DBBackend) initSchema(ctx context.Context) error { func (b *DBBackend) initSchema(ctx context.Context) error {
queries := []string{ queries := []string{
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
@@ -88,7 +92,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 +102,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)
@@ -114,14 +139,10 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
return nil return nil
} }
// resolvePassword prepares a password for storage.
// It is called by syncConfig for every user.
// 1. If PasswordCmd is set, it runs the bash command to get the password.
// 2. If the password is not already a bcrypt hash, it hashes it.
func (b *DBBackend) resolvePassword(u config.User) (string, error) { func (b *DBBackend) resolvePassword(u config.User) (string, error) {
var raw string var raw string
if u.PasswordCmd != "" { if u.PasswordCmd != "" {
cmd := exec.Command("bash", "-c", u.PasswordCmd) cmd := exec.Command("sh", "-c", u.PasswordCmd)
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err) return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err)
@@ -144,12 +165,6 @@ func (b *DBBackend) resolvePassword(u config.User) (string, error) {
return string(hash), nil return string(hash), nil
} }
// syncConfig reconciles the YAML configuration with the PostgreSQL state.
// It is called once at server startup.
// 1. Inserts/Updates all users and their hashed passwords.
// 2. Inserts/Updates all calendars and their access/group rules.
// 3. Builds the in-memory Aggregate maps for fast lookup during requests.
// 4. Performs an 'Orphan Check' to warn the user about DB entries not in config.
func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
tx, err := b.pool.Begin(ctx) tx, err := b.pool.Begin(ctx)
if err != nil { if err != nil {
@@ -168,6 +183,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
prefix := cfg.Server.BasePath() prefix := cfg.Server.BasePath()
// --- Phase 1: User Sync --- // --- Phase 1: User Sync ---
// Inserts/Updates all users and their hashed passwords.
for _, u := range cfg.Users { for _, u := range cfg.Users {
configUserNames[u.Name] = true configUserNames[u.Name] = true
for _, g := range u.Groups { for _, g := range u.Groups {
@@ -188,6 +204,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
} }
// --- Phase 2: Calendar & Access Sync --- // --- Phase 2: Calendar & Access Sync ---
// Inserts/Updates all calendars and their access/group rules.
for _, c := range cfg.Calendars { for _, c := range cfg.Calendars {
path := prefix + fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) path := prefix + fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
configCalendarPaths[path] = true configCalendarPaths[path] = true
@@ -200,10 +217,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 +273,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 {
@@ -263,10 +339,10 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
if configCalendarPaths[p_] { if configCalendarPaths[p_] {
return fmt.Errorf("aggregate %s collides with real calendar path", p_) return fmt.Errorf("aggregate %s collides with real calendar path", p_)
} }
aggCopy := agg aggCopy := agg
b.aggregates[p_] = &aggCopy b.aggregates[p_] = &aggCopy
aggAccess := make(map[string]bool) aggAccess := make(map[string]bool)
aggAccess[agg.Owner] = true aggAccess[agg.Owner] = true
for _, a := range agg.Access { for _, a := range agg.Access {
@@ -319,7 +395,6 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
} }
calRows.Close() calRows.Close()
} }
return tx.Commit(ctx) return tx.Commit(ctx)
} }
@@ -358,13 +433,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. // for a real calendars
// It returns the internal database ID of the calendar if access is granted. // returns internal database ID if granted
// It is called before any read (PROPFIND, GET, REPORT) or write (PUT, DELETE) operation. func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) {
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, 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 +454,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 +472,29 @@ 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. // Privacy engine
// It is called every time a calendar object is retrieved from the database func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) {
// for a user who is NOT the owner.
// It applies the following rules based on the 'CLASS' property of an event/task:
// 1. PRIVATE: Removes the object entirely.
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
// 3. PUBLIC: Passes the object through unchanged.
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, original *ical.Calendar) (*ical.Calendar, error) {
username, err := b.getUsername(ctx) 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
} }
@@ -438,7 +507,7 @@ func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, origin
continue continue
} }
class := b.defaultClass; class := b.defaultClass
if prop := child.Props.Get("CLASS"); prop != nil { if prop := child.Props.Get("CLASS"); prop != nil {
class = strings.ToUpper(prop.Value) class = strings.ToUpper(prop.Value)
} }
@@ -499,7 +568,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 +708,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 +721,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 +732,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 +756,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,10 +770,11 @@ 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)
var calID int var calID int
var ownerName string var ownerName string
err := b.pool.QueryRow(ctx, ` err := b.pool.QueryRow(ctx, `
@@ -715,11 +785,25 @@ 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
} }
for _, obj := range objs { for _, obj := range objs {
// Prepend source name so Diane knows this is a "[calendar]" event // Prepend source name so Diane knows this is a "[calendar]" event
for _, child := range obj.Data.Children { for _, child := range obj.Data.Children {
@@ -746,7 +830,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)
@@ -766,7 +850,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
} }
sourceID := parts[0] sourceID := parts[0]
realFileName := parts[1] realFileName := parts[1]
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID) sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int var calID int
var ownerName string var ownerName string
@@ -788,7 +872,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 +912,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 +939,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"))
} }
@@ -853,13 +951,13 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
// Called during PUT requests when a user saves a change in their calendar app. // Called during PUT requests when a user saves a change in their calendar app.
func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
dirPath := path.Dir(p) + "/" dirPath := path.Dir(p) + "/"
// Aggregates are read-only // Aggregates are read-only
if _, ok := b.aggregates[dirPath]; ok { if _, ok := b.aggregates[dirPath]; ok {
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 +978,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 +1075,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 +1090,386 @@ 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 ---
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,6 +18,13 @@ type Access struct {
} }
type Calendar struct { type Calendar struct {
ID string `yaml:"id"`
Owner string `yaml:"owner"`
Color string `yaml:"color,omitempty"`
Access []Access `yaml:"access,omitempty"`
}
type AddressBook struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Access []Access `yaml:"access,omitempty"` Access []Access `yaml:"access,omitempty"`
@@ -26,6 +33,7 @@ type Calendar struct {
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,24 +52,46 @@ 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"`
Redaction string `yaml:"redaction_text"` EmailDomain string `yaml:"email_domain"`
DefaultClass string `yaml:"default_class"` Redaction string `yaml:"redaction_text"` // "[-]"
DefaultClass string `yaml:"default_class"` // CONFIDENTIAL/PRIVATE/PUBLIC
} }
func (s ServerConfig) BasePath() string { func (s ServerConfig) BasePath() string {
u, err := url.Parse(s.PublicURL) u, err := url.Parse(s.PublicURL)
if err != nil { if err != nil { return "" }
return ""
}
return strings.TrimSuffix(u.Path, "/") return strings.TrimSuffix(u.Path, "/")
} }
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
}
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
Users []User `yaml:"users"` SMTP SMTPConfig `yaml:"smtp"`
Calendars []Calendar `yaml:"calendars"` Users []User `yaml:"users"`
Aggregates []Aggregate `yaml:"aggregates"` Calendars []Calendar `yaml:"calendars"`
AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"`
}
func (c *Config) setDefaults() {
if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" }
if c.Server.Redaction == "" { c.Server.Redaction = "Busy" }
if c.Server.DefaultClass == "" { c.Server.DefaultClass = "CONFIDENTIAL" }
if c.Server.EmailDomain == "" { c.Server.EmailDomain = "nx2.site" }
if c.Database.URL == "" { 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 }
}
func (c *Config) checkConfig() {
if !(slices.Contains([]string{"PUBLIC", "PRIVATE", "CONFIDENTIAL"}, c.Server.DefaultClass)) {
panic("Invaldi Config, default_class")
}
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@@ -82,23 +112,3 @@ func Load(path string) (*Config, error) {
return &cfg, nil return &cfg, nil
} }
func (c *Config) checkConfig() {
if !(slices.Contains([]string{"PUBLIC", "PRIVATE", "CONFIDENTIAL"}, c.Server.DefaultClass)) {
panic("Invaldi Config, default_class")
}
}
func (c *Config) setDefaults() {
if c.Server.BindAddress == "" {
c.Server.BindAddress = ":8080"
}
if c.Server.Redaction == "" {
c.Server.Redaction = "Busy"
}
if c.Server.DefaultClass == "" {
c.Server.DefaultClass = "CONFIDENTIAL"
}
if c.Database.URL == "" {
c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
}
}

194
internal/extra/injector.go Normal file
View File

@@ -0,0 +1,194 @@
package extra
import (
"bytes"
"strings"
"fmt"
"context"
"io"
"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
}
// Add Color To Calendar Propfind
func InjectColor(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()
// this models after the Radicale Response, largely AI code
// 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)
}
// modify caledar probfind
//
// this again modeled after the Radicale response
// Largly AI generated agian
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
}
// 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)
}

BIN
main Executable file

Binary file not shown.

106
main.go
View File

@@ -4,19 +4,24 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"net/http" "net/http"
"net/url" "net/url"
"os"
"slices"
"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/config" "nxcaldav/internal/config"
"nxcaldav/internal/extra"
) )
func main() { func main() {
path := "config.yaml"; // -- GET CONFIG
path := "config.yaml"
if len(os.Args) == 3 { if len(os.Args) == 3 {
if os.Args[1] == "-c" { if os.Args[1] == "-c" {
path = os.Args[2] path = os.Args[2]
@@ -27,30 +32,39 @@ func main() {
log.Fatalf("failed to load config: %v", err) log.Fatalf("failed to load config: %v", err)
} }
// -- GET CONTEXT AND DB
ctx := context.Background() ctx := context.Background()
be, err := backend.NewDBBackend(ctx, cfg) be, err := backend.NewDBBackend(ctx, cfg)
if err != nil { if err != nil {
log.Fatalf("failed to initialize database backend: %v", err) log.Fatalf("failed to initialize database backend: %v", err)
} }
handler := &caldav.Handler{Backend: be} // -- GET CONTEXT AND DB
caldavHandler := &caldav.Handler{Backend: be}
carddavHandler := &carddav.Handler{Backend: be}
publicURL, _ := url.Parse(cfg.Server.PublicURL) publicURL, _ := url.Parse(cfg.Server.PublicURL)
// -- DISCOVERIES
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Proxy-aware normalization:
scheme := r.URL.Scheme
if scheme == "" {
scheme = "http"
}
// set host (because reverse proxies exist)
if publicURL != nil && publicURL.Host != "" { if publicURL != nil && publicURL.Host != "" {
r.Host = publicURL.Host r.Host = publicURL.Host
r.URL.Host = publicURL.Host r.URL.Host = publicURL.Host
// Detect scheme: prioritize X-Forwarded-Proto, then PublicURL // prioritize X-Forwarded-Proto, then PublicURL (e.g. Cloudfalre proxy)
scheme := publicURL.Scheme scheme = publicURL.Scheme
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto scheme = proto
} }
r.URL.Scheme = scheme r.URL.Scheme = scheme
// Also rewrite WebDAV Destination header (used for MOVE/COPY) // Also rewrite WebDAV Destination header (for MOVE/COPY)
if dest := r.Header.Get("Destination"); dest != "" { if dest := r.Header.Get("Destination"); dest != "" {
destURL, err := url.Parse(dest) destURL, err := url.Parse(dest)
if err == nil { if err == nil {
@@ -61,58 +75,94 @@ 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
} }
// DAV 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"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Verify via Database (bcrypt)
valid, err := be.VerifyUser(r.Context(), user, password) valid, err := be.VerifyUser(r.Context(), user, password)
if err != nil { if err != nil {
log.Printf("auth error for %s: %v", user, err) log.Printf("auth error for %s: %v", user, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
if !valid { if !valid {
log.Printf("auth failed for %s", user) log.Printf("auth failed for %s", user)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
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) principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" { // set header for carddav
// If we normalized the request, use the normalized host/scheme for the redirect if slices.Contains([]string{
if publicURL != nil && publicURL.Host != "" { "/.well-known/carddav",
scheme := r.URL.Scheme prefix + "/.well-known/carddav",
if scheme == "" { principalPath,
scheme = "http" strings.TrimSuffix(principalPath, "/"),
} }, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") {
target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath) w.Header().Add("DAV", "addressbook")
http.Redirect(w, r, target, http.StatusMovedPermanently) }
} else {
http.Redirect(w, r, principalPath, http.StatusMovedPermanently) // for caldav discovery
} if slices.Contains([]string{
"/.well-known/caldav",
prefix + "/.well-known/caldav",
}, r.URL.Path) {
http.Redirect(w, r, fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently)
return return
} }
// serve caldav
if strings.Contains(r.URL.Path, "/calendars/") {
if r.Method == "PROPFIND" {
// Calendar colors
// needed because color info is not RFC, so I hacked it in with regex, to look like Radicales response
extra.InjectColor(r, ctx, caldavHandler, w, be)
} else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx))
}
handler.ServeHTTP(w, r.WithContext(ctx)) // serve carddav
} else if strings.Contains(r.URL.Path, "/addressbooks/") {
carddavHandler.ServeHTTP(w, r.WithContext(ctx))
// catch weird requests
} else {
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 server on %s...\n", cfg.Server.BindAddress) 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,

View File

@@ -1,11 +0,0 @@
{ pkgs ? import <nixpkgs> { } }: 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 ];
}