4 Commits

Author SHA1 Message Date
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
11 changed files with 572 additions and 365 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -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
```

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()

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,7 +1,6 @@
package backend package backend
import ( import (
"slices"
"bytes" "bytes"
"context" "context"
"errors" "errors"
@@ -11,6 +10,7 @@ import (
"net/url" "net/url"
"os/exec" "os/exec"
"path" "path"
"slices"
"strings" "strings"
"time" "time"
@@ -26,31 +26,26 @@ import (
"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://nxc.nx2.site/) 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")
emailDomain string // Domain for email addresses (e.g., "nx2.site") emailDomain string // Domain for email addresses (e.g., "example.com" if email is tony@example.com)
smtp config.SMTPConfig // SMTP server configuration smtp config.SMTPConfig
aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions aggregates map[string]*config.Aggregate // In-memory maps...
userAggs map[string][]string // In-memory map of user -> list of aggregate paths they can see userAggs map[string][]string
publicAccess map[string]publicInfo // In-memory map of public path -> internal info 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 {
@@ -84,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 (
@@ -108,35 +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 ( `CREATE TABLE IF NOT EXISTS addressbooks (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
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
)`, )`,
`CREATE TABLE IF NOT EXISTS addressbook_access ( `CREATE TABLE IF NOT EXISTS addressbook_access (
addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE, addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL, mode TEXT NOT NULL,
PRIMARY KEY (addressbook_id, user_id) PRIMARY KEY (addressbook_id, user_id)
)`, )`,
`CREATE TABLE IF NOT EXISTS addressbook_objects ( `CREATE TABLE IF NOT EXISTS addressbook_objects (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE, addressbook_id INTEGER REFERENCES addressbooks(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 (addressbook_id, path) 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)
@@ -145,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)
@@ -175,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 {
@@ -199,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 {
@@ -219,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
@@ -409,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)
} }
@@ -448,9 +433,8 @@ 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 and the granted mode 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, string, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
@@ -501,13 +485,7 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
return calID, mode, 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
// for a user who is NOT the owner (unless they have read-write access).
// It applies the following rules based on the 'CLASS' property of an event/task:
// 1. PRIVATE: Removes the object entirely.
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
// 3. PUBLIC: Passes the object through unchanged.
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) { 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 {
@@ -529,7 +507,7 @@ func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode s
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)
} }
@@ -1143,11 +1121,15 @@ func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, sta
for rows.Next() { for rows.Next() {
var p, dataStr string var p, dataStr string
var calID int var calID int
if err := rows.Scan(&p, &dataStr, &calID); err != nil { continue } if err := rows.Scan(&p, &dataStr, &calID); err != nil {
continue
}
// Verify UID (LIKE is just a hint) // Verify UID (LIKE is just a hint)
calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil { continue } if err != nil {
continue
}
found := false found := false
for _, event := range calendar.Events() { for _, event := range calendar.Events() {
@@ -1264,87 +1246,87 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string {
return color return color
} }
return "" return ""
} }
// --- CardDAV Backend Implementation --- // --- CardDAV ---
func (b *DBBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { func (b *DBBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
return b.prefix + fmt.Sprintf("/%s/addressbooks/", username), nil return b.prefix + fmt.Sprintf("/%s/addressbooks/", username), nil
} }
func (b *DBBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) { func (b *DBBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
username, err := b.getUsername(ctx) username, err := b.getUsername(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rows, err := b.pool.Query(ctx, ` rows, err := b.pool.Query(ctx, `
SELECT path, name, COALESCE(description, '') FROM addressbooks SELECT path, name, COALESCE(description, '') FROM addressbooks
WHERE owner_id = (SELECT id FROM users WHERE name = $1) 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))`, OR id IN (SELECT addressbook_id FROM addressbook_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`,
username) username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var res []carddav.AddressBook var res []carddav.AddressBook
for rows.Next() { for rows.Next() {
var ab carddav.AddressBook var ab carddav.AddressBook
if err := rows.Scan(&ab.Path, &ab.Name, &ab.Description); err != nil { if err := rows.Scan(&ab.Path, &ab.Name, &ab.Description); err != nil {
return nil, err return nil, err
} }
ab.MaxResourceSize = 1000000 ab.MaxResourceSize = 1000000
ab.SupportedAddressData = []carddav.AddressDataType{ ab.SupportedAddressData = []carddav.AddressDataType{
{ContentType: "text/vcard", Version: "3.0"}, {ContentType: "text/vcard", Version: "3.0"},
{ContentType: "text/vcard", Version: "4.0"}, {ContentType: "text/vcard", Version: "4.0"},
} }
res = append(res, ab) res = append(res, ab)
} }
return res, nil return res, nil
} }
func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) { func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) {
if !strings.HasSuffix(p, "/") { if !strings.HasSuffix(p, "/") {
p += "/" p += "/"
} }
var ab carddav.AddressBook 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) 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
} }
return nil, err return nil, err
} }
ab.MaxResourceSize = 1000000 ab.MaxResourceSize = 1000000
ab.SupportedAddressData = []carddav.AddressDataType{ ab.SupportedAddressData = []carddav.AddressDataType{
{ContentType: "text/vcard", Version: "3.0"}, {ContentType: "text/vcard", Version: "3.0"},
{ContentType: "text/vcard", Version: "4.0"}, {ContentType: "text/vcard", Version: "4.0"},
} }
return &ab, nil return &ab, nil
} }
func (b *DBBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error { func (b *DBBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book creation only via config")) return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book creation only via config"))
} }
func (b *DBBackend) DeleteAddressBook(ctx context.Context, p string) error { func (b *DBBackend) DeleteAddressBook(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("address book deletion only via config")) 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) { func (b *DBBackend) checkAddressBookAccess(ctx context.Context, abPath 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(abPath, "/") { if !strings.HasSuffix(abPath, "/") {
abPath += "/" abPath += "/"
} }
var abID int var abID int
@@ -1355,140 +1337,139 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string {
JOIN users u ON a.owner_id = u.id JOIN users u ON a.owner_id = u.id
WHERE a.path = $1`, abPath).Scan(&abID, &ownerName) WHERE a.path = $1`, abPath).Scan(&abID, &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("address book not found")) return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("address book not found"))
} }
return 0, "", err return 0, "", err
} }
if ownerName == username { if ownerName == username {
return abID, "owner", nil return abID, "owner", nil
} }
var mode string var mode string
err = b.pool.QueryRow(ctx, ` err = b.pool.QueryRow(ctx, `
SELECT mode FROM addressbook_access SELECT mode FROM addressbook_access
WHERE addressbook_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`, WHERE addressbook_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
abID, username).Scan(&mode) abID, 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
} }
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 abID, mode, nil return abID, mode, nil
}
func (b *DBBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
if !strings.HasSuffix(p, "/") {
p += "/"
} }
func (b *DBBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { abID, _, err := b.checkAddressBookAccess(ctx, p, "read")
if !strings.HasSuffix(p, "/") { if err != nil {
p += "/" return nil, err
} }
abID, _, err := b.checkAddressBookAccess(ctx, p, "read") rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1", abID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM addressbook_objects WHERE addressbook_id = $1", abID) var res []carddav.AddressObject
if err != nil { for rows.Next() {
return nil, err var obj carddav.AddressObject
} var dataStr string
defer rows.Close() if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
return nil, err
}
var res []carddav.AddressObject card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
for rows.Next() { if err != nil {
var obj carddav.AddressObject continue
var dataStr string }
if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil { obj.Card = card
return nil, err res = append(res, obj)
} }
return res, nil
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) { func (b *DBBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
dirPath := path.Dir(p) + "/" dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "read") abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "read")
if err != nil { if err != nil {
return nil, err return nil, err
} }
var obj carddav.AddressObject var obj carddav.AddressObject
var dataStr string 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) 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
} }
return nil, err return nil, err
} }
card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode() card, err := vcard.NewDecoder(strings.NewReader(dataStr)).Decode()
if err != nil { if err != nil {
return nil, err return nil, err
} }
obj.Card = card obj.Card = card
return &obj, nil return &obj, nil
} }
func (b *DBBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { func (b *DBBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
dirPath := path.Dir(p) + "/" dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write") abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return nil, err return nil, err
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := vcard.NewEncoder(&buf).Encode(card); err != nil { if err := vcard.NewEncoder(&buf).Encode(card); err != nil {
return nil, err return nil, err
} }
dataStr := buf.String() dataStr := buf.String()
etag := fmt.Sprintf(`"%d"`, len(dataStr)) etag := fmt.Sprintf(`"%d"`, len(dataStr))
_, err = b.pool.Exec(ctx, ` _, err = b.pool.Exec(ctx, `
INSERT INTO addressbook_objects (addressbook_id, path, data, etag) VALUES ($1, $2, $3, $4) 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`, ON CONFLICT (addressbook_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
abID, p, dataStr, etag) abID, p, dataStr, etag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &carddav.AddressObject{ return &carddav.AddressObject{
Path: p, Path: p,
Card: card, Card: card,
ETag: etag, ETag: etag,
}, nil }, nil
} }
func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error { func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error {
dirPath := path.Dir(p) + "/" dirPath := path.Dir(p) + "/"
abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write") abID, _, err := b.checkAddressBookAccess(ctx, dirPath, "write")
if err != nil { if err != nil {
return err return err
} }
commandTag, err := b.pool.Exec(ctx, "DELETE FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p) commandTag, err := b.pool.Exec(ctx, "DELETE FROM addressbook_objects WHERE addressbook_id = $1 AND path = $2", abID, p)
if err != nil { if err != nil {
return err return err
} }
if commandTag.RowsAffected() == 0 { if commandTag.RowsAffected() == 0 {
return webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found")) return webdav.NewHTTPError(http.StatusNotFound, errors.New("address book object not found"))
} }
return nil return nil
} }
func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
return b.ListAddressObjects(ctx, p, nil) return b.ListAddressObjects(ctx, p, nil)
} }

View File

@@ -18,20 +18,19 @@ 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"`
Color string `yaml:"color,omitempty"` Color string `yaml:"color,omitempty"`
Access []Access `yaml:"access,omitempty"` Access []Access `yaml:"access,omitempty"`
} }
type AddressBook struct { 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"`
} }
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"` Color string `yaml:"color,omitempty"`
@@ -54,18 +53,16 @@ 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"` 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"` // 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 { type SMTPConfig struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
@@ -74,14 +71,29 @@ type SMTPConfig struct {
} }
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
SMTP SMTPConfig `yaml:"smtp"` SMTP SMTPConfig `yaml:"smtp"`
Users []User `yaml:"users"` Users []User `yaml:"users"`
Calendars []Calendar `yaml:"calendars"` Calendars []Calendar `yaml:"calendars"`
AddressBooks []AddressBook `yaml:"address_books"` AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"` 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) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
@@ -100,32 +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.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
}
}

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"context" "context"
"io" "io"
"log"
"net/http" "net/http"
"nxcaldav/internal/backend" "nxcaldav/internal/backend"
@@ -28,7 +27,8 @@ func (rw *responseWriter) WriteHeader(status int) {
rw.status = status rw.status = status
} }
func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) { // 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) reqBody, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
@@ -38,16 +38,17 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
body := buf.Bytes() body := buf.Bytes()
// this models after the Radicale Response, largely AI code
// 1. Add namespaces to the root multistatus tag // 1. Add namespaces to the root multistatus tag
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`) 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/"`)) 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 // 2. Response processing
reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?</[a-zA-Z0-9]*:?response>`) 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>`) 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>`) 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>`) 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>`) reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?</[a-zA-Z0-9]*:?prop>`)
rePropClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?prop>`) rePropClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?prop>`)
body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte { body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte {
@@ -89,7 +90,7 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
return ps return ps
}) })
// 3. If no 200 OK propstat was found, create one! // 3. If no 200 OK propstat was found, create one
if !has200 { if !has200 {
newPropstat := fmt.Sprintf("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", props) 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>`) reResponseClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?response>`)
@@ -106,6 +107,10 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
w.Write(body) 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) { func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
reqBody, _ := io.ReadAll(r.Body) reqBody, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
@@ -158,6 +163,7 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
return resp return resp
} }
// 2. Try to inject into an existing 200 OK propstat
has200 := false has200 := false
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte { resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
if reStatusOk.Match(ps) { if reStatusOk.Match(ps) {
@@ -171,6 +177,7 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
return ps return ps
}) })
// 3. If no 200 OK propstat was found, create one
if !has200 { if !has200 {
newPropstat := fmt.Sprintf("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", props) 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>`) reResponseClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?response>`)
@@ -178,7 +185,6 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
return append([]byte(newPropstat), closeTag...) return append([]byte(newPropstat), closeTag...)
}) })
} }
return resp return resp
}) })

BIN
main Executable file

Binary file not shown.

176
main.go
View File

@@ -1,28 +1,27 @@
package main package main
import ( 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" "github.com/emersion/go-webdav/carddav"
"nxcaldav/internal/backend"
"nxcaldav/internal/extra" "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]
@@ -33,51 +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)
} }
// -- GET CONTEXT AND DB
caldavHandler := &caldav.Handler{Backend: be} caldavHandler := &caldav.Handler{Backend: be}
carddavHandler := &carddav.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) { // -- DISCOVERIES
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:
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 {
@@ -95,94 +82,87 @@ func main() {
return return
} }
// caldav access needs auth // 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("[user: %s] %s %s", user, r.Method, r.URL.Path) log.Printf("[user: %s] %s %s", user, r.Method, r.URL.Path)
principalPath := prefix + fmt.Sprintf("/%s/", user) principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath)
// Add addressbook to DAV header for discovery/access paths // set header for carddav
// This includes principal path and .well-known if slices.Contains([]string{
isCardDAVPath := strings.Contains(r.URL.Path, "/addressbooks/") || "/.well-known/carddav",
r.URL.Path == "/.well-known/carddav" || prefix + "/.well-known/carddav",
r.URL.Path == prefix+"/.well-known/carddav" || principalPath,
r.URL.Path == principalPath || strings.TrimSuffix(principalPath, "/"),
r.URL.Path == strings.TrimSuffix(principalPath, "/") }, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") {
w.Header().Add("DAV", "addressbook")
}
if isCardDAVPath { // for caldav discovery
w.Header().Add("DAV", "addressbook") 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
}
ctx := context.WithValue(r.Context(), "principal", principalPath) // 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))
}
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" || // serve carddav
r.URL.Path == "/.well-known/carddav" || r.URL.Path == prefix+"/.well-known/carddav" { } else if strings.Contains(r.URL.Path, "/addressbooks/") {
// If normalized request, use normalized host/scheme for redirect carddavHandler.ServeHTTP(w, r.WithContext(ctx))
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 // catch weird requests
if strings.Contains(r.URL.Path, "/calendars/") { } else {
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) 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,

View File

@@ -5,6 +5,7 @@
ics ics
caldav caldav
pyyaml pyyaml
psycopg2
]); ]);
in pkgs.mkShell { in pkgs.mkShell {
buildInputs = [ python-with-my-packages ]; buildInputs = [ python-with-my-packages ];