From 579ba8f5eff09c41ee91a8fd1bbdd2c21e94b778 Mon Sep 17 00:00:00 2001 From: "Lennart J. Kurzweg (Nx2)" Date: Wed, 22 Apr 2026 17:30:07 +0200 Subject: [PATCH] cleanups --- .gitignore | 2 + .ignore | 2 + README.md | 53 +++ export_events.py | 77 +++++ import_events.py | 124 +++++++ internal/backend/db.go | 393 +++++++++++------------ internal/config/config.go | 83 ++--- internal/extra/{color.go => injector.go} | 23 +- main.go | 161 ++++------ shell.nix | 1 + 10 files changed, 565 insertions(+), 354 deletions(-) create mode 100644 export_events.py create mode 100644 import_events.py rename internal/extra/{color.go => injector.go} (86%) diff --git a/.gitignore b/.gitignore index 6aee0f3..94c920d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ server.log mem.go nxcaldav +in/ +out/ diff --git a/.ignore b/.ignore index e6b7402..0dbf9fd 100644 --- a/.ignore +++ b/.ignore @@ -2,3 +2,5 @@ # server.log mem.go nxcaldav +in/ +out/ diff --git a/README.md b/README.md index ad15f72..3ed90db 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,56 @@ UPDATE calendar_objects SET path = regexp_replace(path, '/old/', '/new/')' WHERE ```sql 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 +``` + + diff --git a/export_events.py b/export_events.py new file mode 100644 index 0000000..5e04d26 --- /dev/null +++ b/export_events.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +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() diff --git a/import_events.py b/import_events.py new file mode 100644 index 0000000..b83d4c3 --- /dev/null +++ b/import_events.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +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() diff --git a/internal/backend/db.go b/internal/backend/db.go index ecfc51c..bc86752 100644 --- a/internal/backend/db.go +++ b/internal/backend/db.go @@ -1,7 +1,6 @@ package backend import ( - "slices" "bytes" "context" "errors" @@ -11,6 +10,7 @@ import ( "net/url" "os/exec" "path" + "slices" "strings" "time" @@ -26,31 +26,26 @@ import ( "nxcaldav/internal/config" ) +// for ICS access type publicInfo struct { InternalPath string Mode string // e.g., "future-only" } -// DBBackend implements the caldav.Backend interface using a PostgreSQL database. -// It manages users, calendars, and their events/tasks with built-in support for -// access control, privacy redaction, and aggregate (virtual) calendars. +// DBBackend = caldav.Backend interface (PostgreSQL) type DBBackend struct { pool *pgxpool.Pool // Connection pool to PostgreSQL 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]") defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL") - emailDomain string // Domain for email addresses (e.g., "nx2.site") - smtp config.SMTPConfig // SMTP server configuration - aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions - userAggs map[string][]string // In-memory map of user -> list of aggregate paths they can see - publicAccess map[string]publicInfo // In-memory map of public path -> internal info + emailDomain string // Domain for email addresses (e.g., "example.com" if email is tony@example.com) + smtp config.SMTPConfig + 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) { pool, err := pgxpool.New(ctx, cfg.Database.URL) 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. -// We use ON DELETE CASCADE extensively so that deleting a user or calendar -// automatically wipes all related events and access rules in the DB. +// it uses ON DELETE CASCADE extensively func (b *DBBackend) initSchema(ctx context.Context) error { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( @@ -108,35 +102,35 @@ func (b *DBBackend) initSchema(ctx context.Context) error { PRIMARY KEY (calendar_id, user_id) )`, `CREATE TABLE IF NOT EXISTS calendar_objects ( - id SERIAL PRIMARY KEY, - calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, - path TEXT NOT NULL, - data TEXT NOT NULL, - etag TEXT NOT NULL, - UNIQUE (calendar_id, path) + id SERIAL PRIMARY KEY, + calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE, + path TEXT NOT NULL, + data TEXT NOT NULL, + etag TEXT NOT NULL, + UNIQUE (calendar_id, path) )`, `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 + 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) + 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) + 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 { if _, err := b.pool.Exec(ctx, q); err != nil { return fmt.Errorf("failed to execute schema query: %v", err) @@ -145,14 +139,10 @@ func (b *DBBackend) initSchema(ctx context.Context) error { 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) { var raw string if u.PasswordCmd != "" { - cmd := exec.Command("bash", "-c", u.PasswordCmd) + cmd := exec.Command("sh", "-c", u.PasswordCmd) out, err := cmd.Output() if err != nil { 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 } -// 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 { tx, err := b.pool.Begin(ctx) if err != nil { @@ -199,6 +183,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { prefix := cfg.Server.BasePath() // --- Phase 1: User Sync --- + // Inserts/Updates all users and their hashed passwords. for _, u := range cfg.Users { configUserNames[u.Name] = true 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 --- + // Inserts/Updates all calendars and their access/group rules. for _, c := range cfg.Calendars { path := prefix + fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) configCalendarPaths[path] = true @@ -353,10 +339,10 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { if configCalendarPaths[p_] { return fmt.Errorf("aggregate %s collides with real calendar path", p_) } - + aggCopy := agg b.aggregates[p_] = &aggCopy - + aggAccess := make(map[string]bool) aggAccess[agg.Owner] = true for _, a := range agg.Access { @@ -409,7 +395,6 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error { } calRows.Close() } - return tx.Commit(ctx) } @@ -448,9 +433,8 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) { // --- Internal Security & Privacy Helpers --- -// checkAccess verifies if the current user has the required permission for a real calendar. -// It returns the internal database ID of the calendar and the granted mode if access is granted. -// It is called before any read (PROPFIND, GET, REPORT) or write (PUT, DELETE) operation. +// for a real calendars +// returns internal database ID if granted func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) { username, err := b.getUsername(ctx) if err != nil { @@ -501,13 +485,7 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir return calID, mode, nil } -// filterCalendar is the privacy enforcement engine of the server. -// 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. +// Privacy engine func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) { username, err := b.getUsername(ctx) if err != nil { @@ -529,7 +507,7 @@ func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode s continue } - class := b.defaultClass; + class := b.defaultClass if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) } @@ -793,10 +771,10 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) { var res []caldav.CalendarObject username, _ := b.getUsername(ctx) - + for _, sourceID := range agg.Sources { sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID) - + var calID int var ownerName string err := b.pool.QueryRow(ctx, ` @@ -825,7 +803,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag if err != nil { continue } - + for _, obj := range objs { // Prepend source name so Diane knows this is a "[calendar]" event for _, child := range obj.Data.Children { @@ -872,7 +850,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav } sourceID := parts[0] realFileName := parts[1] - + sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID) var calID int var ownerName string @@ -973,7 +951,7 @@ 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. func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { dirPath := path.Dir(p) + "/" - + // Aggregates are read-only if _, ok := b.aggregates[dirPath]; ok { return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only")) @@ -1012,11 +990,11 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i } // --- Case A: User is the ATTENDEE updating their status --- - // If the user is NOT the organizer, but is an attendee, find the organizer's + // 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"] { @@ -1120,7 +1098,7 @@ func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, sta // 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", + 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) @@ -1132,7 +1110,7 @@ func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, sta 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 || '%'`, + 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) @@ -1143,11 +1121,15 @@ func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, sta for rows.Next() { var p, dataStr string 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) calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() - if err != nil { continue } + if err != nil { + continue + } found := false for _, event := range calendar.Events() { @@ -1264,87 +1246,87 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string { return color } 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) if err != nil { - return "", err + return "", err } 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) if err != nil { - return nil, err + 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) + username) if err != nil { - return nil, err + 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) + 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) { +func (b *DBBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) { if !strings.HasSuffix(p, "/") { - 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 + 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"}, + {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 { +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 { +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) { +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 + return 0, "", err } if !strings.HasSuffix(abPath, "/") { - abPath += "/" + abPath += "/" } 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 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 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 + 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) + 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 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 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 += "/" } - 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 + } - 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() - 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 + } - 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 + 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 - } + 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 - } + 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 + 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 - } + 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)) + 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, ` + _, 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 - } + abID, p, dataStr, etag) + if err != nil { + return nil, err + } - return &carddav.AddressObject{ - Path: p, - Card: card, - ETag: etag, - }, nil + 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 - } + 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 + 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) + return b.ListAddressObjects(ctx, p, nil) } - diff --git a/internal/config/config.go b/internal/config/config.go index 2a60239..6e28638 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,20 +18,19 @@ type Access struct { } type Calendar struct { - ID string `yaml:"id"` - Owner string `yaml:"owner"` - Color string `yaml:"color,omitempty"` - Access []Access `yaml:"access,omitempty"` + 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"` - Owner string `yaml:"owner"` - Access []Access `yaml:"access,omitempty"` + ID string `yaml:"id"` + Owner string `yaml:"owner"` + Access []Access `yaml:"access,omitempty"` } type Aggregate struct { - ID string `yaml:"id"` Owner string `yaml:"owner"` Color string `yaml:"color,omitempty"` @@ -54,18 +53,16 @@ type ServerConfig struct { BindAddress string `yaml:"bind_address"` PublicURL string `yaml:"public_url"` EmailDomain string `yaml:"email_domain"` - Redaction string `yaml:"redaction_text"` - DefaultClass string `yaml:"default_class"` + Redaction string `yaml:"redaction_text"` // "[-]" + DefaultClass string `yaml:"default_class"` // CONFIDENTIAL/PRIVATE/PUBLIC } - func (s ServerConfig) BasePath() string { u, err := url.Parse(s.PublicURL) - if err != nil { - return "" - } + if err != nil { return "" } return strings.TrimSuffix(u.Path, "/") } + type SMTPConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` @@ -74,14 +71,29 @@ type SMTPConfig struct { } type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - SMTP SMTPConfig `yaml:"smtp"` - Users []User `yaml:"users"` - Calendars []Calendar `yaml:"calendars"` - AddressBooks []AddressBook `yaml:"address_books"` - Aggregates []Aggregate `yaml:"aggregates"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + SMTP SMTPConfig `yaml:"smtp"` + Users []User `yaml:"users"` + Calendars []Calendar `yaml:"calendars"` + AddressBooks []AddressBook `yaml:"address_books"` + Aggregates []Aggregate `yaml:"aggregates"` } +func (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) { f, err := os.Open(path) if err != nil { @@ -100,32 +112,3 @@ func Load(path string) (*Config, error) { 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 - } -} diff --git a/internal/extra/color.go b/internal/extra/injector.go similarity index 86% rename from internal/extra/color.go rename to internal/extra/injector.go index 60b52bd..9ab180c 100644 --- a/internal/extra/color.go +++ b/internal/extra/injector.go @@ -28,7 +28,8 @@ func (rw *responseWriter) WriteHeader(status int) { rw.status = status } -func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) { +// 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)) @@ -38,16 +39,17 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht 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.*?>.*?`) - reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)`) - rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?`) - reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK`) - reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?`) + reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?`) + reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)`) + rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?`) + reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK`) + reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?`) rePropClose := regexp.MustCompile(``) body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte { @@ -89,7 +91,7 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht return ps }) - // 3. If no 200 OK propstat was found, create one! + // 3. If no 200 OK propstat was found, create one if !has200 { newPropstat := fmt.Sprintf("%sHTTP/1.1 200 OK", props) reResponseClose := regexp.MustCompile(``) @@ -106,6 +108,10 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht 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)) @@ -158,6 +164,7 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http. 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) { @@ -171,6 +178,7 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http. return ps }) + // 3. If no 200 OK propstat was found, create one if !has200 { newPropstat := fmt.Sprintf("%sHTTP/1.1 200 OK", props) reResponseClose := regexp.MustCompile(``) @@ -178,7 +186,6 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http. return append([]byte(newPropstat), closeTag...) }) } - return resp }) diff --git a/main.go b/main.go index bb81485..35aea7a 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,28 @@ package main import ( - "context" - "fmt" - "log" - "os" - + "context" + "fmt" + "log" "net/http" "net/url" + "os" + "slices" "strings" "time" "github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/carddav" - "nxcaldav/internal/backend" - "nxcaldav/internal/extra" + "nxcaldav/internal/backend" "nxcaldav/internal/config" + "nxcaldav/internal/extra" ) func main() { + // -- GET CONFIG path := "config.yaml"; if len(os.Args) == 3 { if os.Args[1] == "-c" { @@ -33,51 +34,39 @@ func main() { log.Fatalf("failed to load config: %v", err) } + // -- GET CONTEXT AND DB ctx := context.Background() be, err := backend.NewDBBackend(ctx, cfg) if err != nil { log.Fatalf("failed to initialize database backend: %v", err) } + + // -- GET CONTEXT AND DB caldavHandler := &caldav.Handler{Backend: be} carddavHandler := &carddav.Handler{Backend: be} publicURL, _ := url.Parse(cfg.Server.PublicURL) - http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) { - p := r.URL.Query().Get("path") - attendee := r.URL.Query().Get("attendee") - status := r.URL.Query().Get("status") - - if p == "" || attendee == "" || status == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - err := be.RespondToInvitation(r.Context(), p, attendee, status) - if err != nil { - log.Printf("[email] Error handling response: %v", err) - http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, "

Response recorded

You have %s the invitation for %s.

", strings.ToLower(status), attendee) - }) + // -- DISCOVERIES 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 != "" { r.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 if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { scheme = proto } 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 != "" { destURL, err := url.Parse(dest) if err == nil { @@ -95,92 +84,84 @@ func main() { return } - // caldav access needs auth + // DAV needs auth user, password, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - - // Verify via Database (bcrypt) valid, err := be.VerifyUser(r.Context(), user, password) if err != nil { log.Printf("auth error for %s: %v", user, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - if !valid { log.Printf("auth failed for %s", user) http.Error(w, "Unauthorized", http.StatusUnauthorized) 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 - // This includes principal path and .well-known - isCardDAVPath := strings.Contains(r.URL.Path, "/addressbooks/") || - r.URL.Path == "/.well-known/carddav" || - r.URL.Path == prefix+"/.well-known/carddav" || - r.URL.Path == principalPath || - r.URL.Path == strings.TrimSuffix(principalPath, "/") - - if isCardDAVPath { - w.Header().Add("DAV", "addressbook") - } + // set header for carddav + if slices.Contains([]string{ + "/.well-known/carddav", + prefix + "/.well-known/carddav", + principalPath, + strings.TrimSuffix(principalPath, "/"), + }, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") { + w.Header().Add("DAV", "addressbook") + } - ctx := context.WithValue(r.Context(), "principal", principalPath) + // 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 + } - if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" || - r.URL.Path == "/.well-known/carddav" || r.URL.Path == prefix+"/.well-known/carddav" { - // If normalized request, use normalized host/scheme for redirect - if publicURL != nil && publicURL.Host != "" { - scheme := r.URL.Scheme - if scheme == "" { - scheme = "http" - } - target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath) - http.Redirect(w, r, target, http.StatusMovedPermanently) - } else { - http.Redirect(w, r, principalPath, http.StatusMovedPermanently) - } - return - } + // 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)) + } - // needed because color info is not RFC, so regex hack - if strings.Contains(r.URL.Path, "/calendars/") { - if r.Method == "PROPFIND" { - extra.AddColorToCalendarPropfind(r, ctx, caldavHandler, w, be) - } else { - caldavHandler.ServeHTTP(w, r.WithContext(ctx)) - } - } else if strings.Contains(r.URL.Path, "/addressbooks/") { - carddavHandler.ServeHTTP(w, r.WithContext(ctx)) + // 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 { - // 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) + 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{ diff --git a/shell.nix b/shell.nix index 652670a..bb5932f 100644 --- a/shell.nix +++ b/shell.nix @@ -5,6 +5,7 @@ ics caldav pyyaml + psycopg2 ]); in pkgs.mkShell { buildInputs = [ python-with-my-packages ];