13 Commits

Author SHA1 Message Date
Lennart J. Kurzweg (Nx2)
77498b6261 fix orphans 2026-04-30 20:14:21 +02:00
Lennart J. Kurzweg (Nx2)
960c080f1c fix aggregate source discrepancy 2026-04-24 19:08:36 +02:00
Lennart J. Kurzweg (Nx2)
c420e03ca1 add shebangs 2026-04-24 17:06:18 +02:00
Lennart J. Kurzweg (Nx2)
e496c29101 yeah 2026-04-23 23:09:24 +02:00
Lennart J. Kurzweg (Nx2)
47f12834c1 hardcode sh 2026-04-23 18:04:34 +02:00
Lennart J. Kurzweg (Nx2)
b4a65a1af4 gitignore 2026-04-23 17:33:35 +02:00
Lennart J. Kurzweg (Nx2)
65aeeda263 Merge branch 'master' of ssh://ssh.nx2.site:50022/nx2/nxcaldav 2026-04-23 17:32:33 +02:00
Lennart J. Kurzweg (Nx2)
f61e014d2a smtp pw 2026-04-23 17:32:30 +02:00
ce6a5c7477 Delete shell.nix 2026-04-23 17:21:10 +02:00
Lennart J. Kurzweg (Nx2)
f66f58f67f no shebang 2026-04-23 17:17:52 +02:00
Lennart J. Kurzweg (Nx2)
5f036dbc89 fixed frfr 2026-04-22 20:32:48 +02:00
Lennart J. Kurzweg (Nx2)
6394c8496d remove log 2026-04-22 17:46:23 +02:00
Lennart J. Kurzweg (Nx2)
579ba8f5ef cleanups 2026-04-22 17:30:07 +02:00
12 changed files with 685 additions and 473 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
.direnv .direnv
server.log server.log
shell.nix
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
```

View File

@@ -3,74 +3,21 @@ database:
server: server:
bind_address: 0.0.0.0:14243 bind_address: 0.0.0.0:14243
default_class: CONFIDENTIAL default_class: CONFIDENTIAL
public_url: http://nxc.nx2.site/ public_url: http://example.com/
email_domain: nx2.site email_domain: example.com
redaction_text: '[-]' redaction_text: '[-]'
smtp: smtp:
host: localhost host: localhost
port: 587 port: 587
user: nxcaldav@nx2.site user: nxcaldav@nx2.site
password: Vastly-Wrinkle9-Corsage password_cmd: echo "Vastly-Wrinkle9-Corsage"
users: users:
- name: daniel
password: ll
groups:
- family
- name: lennart
password: ll
groups:
- family
- name: shared
password: Oxidant-Ageless3-Dispersed
calendars: calendars:
- id: preservation
owner: lennart
color: '#F6F5F4'
- id: effort
owner: lennart
color: '#FF0000'
- id: experience
owner: lennart
color: '#2C33FF'
- id: leisure
owner: lennart
color: '#10B400'
- id: daniel
owner: daniel
color: '#ff2222'
- access:
- group: family
mode: read-write
id: family
owner: shared
color: '#999999'
address_books: address_books:
- id: contacts
owner: lennart
- id: contacts
owner: daniel
- id: family
owner: shared
access:
- group: family
mode: read-write
aggregates: aggregates:
- access:
- group: family
mode: read-only
- ics: future-only
id: lennart-aggregat
owner: lennart
color: '#dd9999'
sources:
- preservation
- effort
- experience
- leisure
- family

77
export_events.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python
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()

124
import_events.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python
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"
@@ -9,8 +8,8 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os/exec"
"path" "path"
"slices"
"strings" "strings"
"time" "time"
@@ -26,31 +25,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 +78,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 (
@@ -109,7 +102,7 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
)`, )`,
`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 NOT NULL 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,
@@ -117,20 +110,20 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
)`, )`,
`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 NOT NULL 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 NOT NULL REFERENCES addressbooks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL 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 NOT NULL 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,
@@ -145,21 +138,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 raw, err := config.ResolvePassword(u.Password, u.PasswordCmd)
if u.PasswordCmd != "" {
cmd := exec.Command("bash", "-c", u.PasswordCmd)
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 resolve password for %s: %v", u.Name, err)
}
raw = strings.TrimSpace(string(out))
} else {
raw = u.Password
} }
// If it already looks like a bcrypt hash, return as is. // If it already looks like a bcrypt hash, return as is.
@@ -175,12 +157,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 +175,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 +196,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
@@ -251,6 +229,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
if a.User != "" { if a.User != "" {
tUsers = append(tUsers, a.User) tUsers = append(tUsers, a.User)
} }
tUsers = append(tUsers, a.Users...)
if a.Group != "" { if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...) tUsers = append(tUsers, groupMembers[a.Group]...)
} }
@@ -279,6 +258,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
for _, a := range c.Access { for _, a := range c.Access {
if a.ICS != "" { if a.ICS != "" {
pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID) pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID)
log.Printf("pp: %s", pubPath)
b.publicAccess[pubPath] = publicInfo{ b.publicAccess[pubPath] = publicInfo{
InternalPath: path, InternalPath: path,
Mode: a.ICS, Mode: a.ICS,
@@ -321,6 +301,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
if a.User != "" { if a.User != "" {
tUsers = append(tUsers, a.User) tUsers = append(tUsers, a.User)
} }
tUsers = append(tUsers, a.Users...)
if a.Group != "" { if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...) tUsers = append(tUsers, groupMembers[a.Group]...)
} }
@@ -372,6 +353,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
if a.User != "" { if a.User != "" {
tUsers = append(tUsers, a.User) tUsers = append(tUsers, a.User)
} }
tUsers = append(tUsers, a.Users...)
if a.Group != "" { if a.Group != "" {
tUsers = append(tUsers, groupMembers[a.Group]...) tUsers = append(tUsers, groupMembers[a.Group]...)
} }
@@ -409,7 +391,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 +429,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 +481,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 +503,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)
} }
@@ -795,14 +769,14 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
username, _ := b.getUsername(ctx) username, _ := b.getUsername(ctx)
for _, sourceID := range agg.Sources { for _, sourceID := range agg.Sources {
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int var calID int
var ownerName string var ownerName string
var sourcePath string
err := b.pool.QueryRow(ctx, ` err := b.pool.QueryRow(ctx, `
SELECT c.id, u.name FROM calendars c SELECT c.id, u.name, c.path FROM calendars c
JOIN users u ON c.owner_id = u.id JOIN users u ON c.owner_id = u.id
WHERE c.path = $1`, sourcePath).Scan(&calID, &ownerName) WHERE c.name = $1`, sourceID).Scan(&calID, &ownerName, &sourcePath)
if err != nil { if err != nil {
continue continue
} }
@@ -827,7 +801,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
} }
for _, obj := range objs { for _, obj := range objs {
// Prepend source name so Diane knows this is a "[calendar]" event // Prepend source name so user knows this is a "[calendar]" event
for _, child := range obj.Data.Children { for _, child := range obj.Data.Children {
if child.Name == "VEVENT" || child.Name == "VTODO" { if child.Name == "VEVENT" || child.Name == "VTODO" {
descr := child.Props.Get("DESCRIPTION") descr := child.Props.Get("DESCRIPTION")
@@ -858,7 +832,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
fileName := path.Base(p) fileName := path.Base(p)
// Step 1: Check if this is a request for a virtual item in an aggregate // Step 1: Check if this is a request for a virtual item in an aggregate
if agg, ok := b.aggregates[dirPath]; ok { if _, ok := b.aggregates[dirPath]; ok {
hasAccess := slices.Contains(b.userAggs[username], dirPath) hasAccess := slices.Contains(b.userAggs[username], dirPath)
if !hasAccess { if !hasAccess {
@@ -873,10 +847,10 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
sourceID := parts[0] sourceID := parts[0]
realFileName := parts[1] realFileName := parts[1]
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int var calID int
var ownerName string var ownerName string
err := b.pool.QueryRow(ctx, "SELECT c.id, u.name FROM calendars c JOIN users u ON c.owner_id = u.id WHERE c.path = $1", sourcePath).Scan(&calID, &ownerName) var sourcePath string
err := b.pool.QueryRow(ctx, "SELECT c.id, u.name, c.path FROM calendars c JOIN users u ON c.owner_id = u.id WHERE c.name = $1", sourceID).Scan(&calID, &ownerName, &sourcePath)
if err != nil { if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("source calendar not found")) return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("source calendar not found"))
} }
@@ -1143,11 +1117,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() {
@@ -1266,7 +1244,7 @@ func (b *DBBackend) GetColor(ctx context.Context, p string) string {
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)
@@ -1492,3 +1470,17 @@ func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *ca
return b.ListAddressObjects(ctx, p, nil) return b.ListAddressObjects(ctx, p, nil)
} }
func (b *DBBackend) HasAddressBooks(ctx context.Context) (bool, error) {
username, err := b.getUsername(ctx)
if err != nil {
return false, err
}
var exists bool
err = b.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 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).Scan(&exists)
return exists, err
}

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/emersion/go-ical" "github.com/emersion/go-ical"
"nxcaldav/internal/config"
) )
// sendInvitation sends an iMIP (RFC 6047) invitation email. // sendInvitation sends an iMIP (RFC 6047) invitation email.
@@ -99,8 +100,14 @@ func (b *DBBackend) sendInvitation(senderName, recipientEmail, summary, descript
if err = c.StartTLS(tlsConfig); err != nil { return err } if err = c.StartTLS(tlsConfig); err != nil { return err }
} }
} }
if b.smtp.User != "" && b.smtp.Password != "" {
auth := smtp.PlainAuth("", b.smtp.User, b.smtp.Password, b.smtp.Host) smtpPassword, err := config.ResolvePassword(b.smtp.Password, b.smtp.PasswordCmd)
if err != nil {
return fmt.Errorf("failed to resolve SMTP password: %v", err)
}
if b.smtp.User != "" && smtpPassword != "" {
auth := smtp.PlainAuth("", b.smtp.User, smtpPassword, b.smtp.Host)
if err = c.Auth(auth); err != nil { return err } if err = c.Auth(auth); err != nil { return err }
} }

View File

@@ -1,8 +1,10 @@
package config package config
import ( import (
"fmt"
"net/url" "net/url"
"os" "os"
"os/exec"
"slices" "slices"
"strings" "strings"
@@ -11,6 +13,7 @@ import (
type Access struct { type Access struct {
User string `yaml:"user,omitempty"` User string `yaml:"user,omitempty"`
Users []string `yaml:"users,omitempty"`
Group string `yaml:"group,omitempty"` Group string `yaml:"group,omitempty"`
Groups string `yaml:"groups,omitempty"` Groups string `yaml:"groups,omitempty"`
Mode string `yaml:"mode"` // "read-only" or "read-write" Mode string `yaml:"mode"` // "read-only" or "read-write"
@@ -31,7 +34,6 @@ type AddressBook struct {
} }
type Aggregate struct { type Aggregate struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Color string `yaml:"color,omitempty"` Color string `yaml:"color,omitempty"`
@@ -54,23 +56,22 @@ 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"`
User string `yaml:"user"` User string `yaml:"user"`
Password string `yaml:"password"` Password string `yaml:"password"`
PasswordCmd string `yaml:"password_cmd"`
} }
type Config struct { type Config struct {
@@ -82,6 +83,33 @@ type Config struct {
AddressBooks []AddressBook `yaml:"address_books"` AddressBooks []AddressBook `yaml:"address_books"`
Aggregates []Aggregate `yaml:"aggregates"` Aggregates []Aggregate `yaml:"aggregates"`
} }
func ResolvePassword(password, passwordCmd string) (string, error) {
if passwordCmd != "" {
cmd := exec.Command("sh", "-c", passwordCmd)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to run password command: %v", err)
}
return strings.TrimSpace(string(out)), nil
}
return password, nil
}
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 +128,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,9 +38,20 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
body := buf.Bytes() body := buf.Bytes()
// 1. Add namespaces to the root multistatus tag // this models after the Radicale Response, largely AI code
// 1. Add namespaces to the root multistatus tag only if they are missing
if !bytes.Contains(body, []byte("xmlns:ICAL=")) {
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/"`))
}
if !bytes.Contains(body, []byte("xmlns:C=")) && !bytes.Contains(body, []byte("xmlns:c=")) {
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:C="urn:ietf:params:xml:ns:caldav"`))
}
if !bytes.Contains(body, []byte("xmlns:CS=")) {
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
body = reMultistatus.ReplaceAll(body, []byte(`$1 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>`)
@@ -61,7 +72,7 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
return resp return resp
} }
// 1. Strip any existing conflicting tags that might be in 404 blocks // 1. Strip any existing conflicting tags that might be in 404 blocks (non-greedy)
reStrip := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?/>|<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?>.*?</[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order)>`) reStrip := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?/>|<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?>.*?</[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order)>`)
resp = reStrip.ReplaceAll(resp, []byte("")) resp = reStrip.ReplaceAll(resp, []byte(""))
@@ -89,7 +100,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 +117,23 @@ func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler ht
w.Write(body) w.Write(body)
} }
func HandleDiscoveryOptions(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
buf := &bytes.Buffer{}
rw := &responseWriter{w, buf, http.StatusOK}
handler.ServeHTTP(rw, r.WithContext(ctx))
if has, _ := be.HasAddressBooks(ctx); has {
dav := w.Header().Get("DAV")
if dav == "" {
w.Header().Set("DAV", "1, 3, addressbook, calendar-access")
} else if !strings.Contains(dav, "addressbook") {
w.Header().Set("DAV", dav+", addressbook")
}
}
w.WriteHeader(rw.status)
w.Write(buf.Bytes())
}
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))
@@ -116,9 +144,19 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
body := buf.Bytes() body := buf.Bytes()
// 1. Unconditionally add namespaces to root tag calHome, _ := be.CalendarHomeSetPath(ctx)
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`) cardHome, _ := be.AddressBookHomeSetPath(ctx)
body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav"`)) hasAddressBooks, _ := be.HasAddressBooks(ctx)
// Ensure DAV: addressbook header is present if user has address books
if hasAddressBooks {
dav := w.Header().Get("DAV")
if dav == "" {
w.Header().Set("DAV", "1, 3, addressbook, calendar-access")
} else if !strings.Contains(dav, "addressbook") {
w.Header().Set("DAV", dav+", addressbook")
}
}
// 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>`)
@@ -134,10 +172,7 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
return resp return resp
} }
calHome, _ := be.CalendarHomeSetPath(ctx) // Strip these tags ONLY from non-200 propstats to avoid duplicates or 404 overrides
cardHome, _ := be.AddressBookHomeSetPath(ctx)
// Strip these tags ONLY from non-200 propstats
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte { resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
if !reStatusOk.Match(ps) { if !reStatusOk.Match(ps) {
reTags := regexp.MustCompile(`(?s)<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?/>|<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?>.*?</[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set)>`) reTags := regexp.MustCompile(`(?s)<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?/>|<[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set).*?>.*?</[a-zA-Z0-9:]*(calendar-home-set|addressbook-home-set)>`)
@@ -147,17 +182,20 @@ func HandleDiscoveryPropfind(r *http.Request, ctx context.Context, handler http.
}) })
props := "" props := ""
// Inject calendar-home-set if missing (with local namespace definition for safety)
if calHome != "" && !strings.Contains(string(resp), "calendar-home-set") { if calHome != "" && !strings.Contains(string(resp), "calendar-home-set") {
props += fmt.Sprintf("<C:calendar-home-set><href xmlns=\"DAV:\">%s</href></C:calendar-home-set>", calHome) props += fmt.Sprintf("<C:calendar-home-set xmlns:C=\"urn:ietf:params:xml:ns:caldav\"><href xmlns=\"DAV:\">%s</href></C:calendar-home-set>", calHome)
} }
if cardHome != "" && !strings.Contains(string(resp), "addressbook-home-set") { // Inject addressbook-home-set if missing and user has address books
props += fmt.Sprintf("<card:addressbook-home-set><href xmlns=\"DAV:\">%s</href></card:addressbook-home-set>", cardHome) if hasAddressBooks && cardHome != "" && !strings.Contains(string(resp), "addressbook-home-set") {
props += fmt.Sprintf("<CARD:addressbook-home-set xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><href xmlns=\"DAV:\">%s</href></CARD:addressbook-home-set>", cardHome)
} }
if props == "" { if props == "" {
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 +209,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 +217,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
}) })

125
main.go
View File

@@ -4,25 +4,24 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"net/http" "net/http"
"net/url" "net/url"
"os"
"slices"
"strings" "strings"
"time" "time"
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"github.com/emersion/go-webdav/carddav" "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,22 +82,19 @@ 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)
@@ -120,63 +104,60 @@ func main() {
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)
// 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")
}
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" || // set header for carddav if user has address books
r.URL.Path == "/.well-known/carddav" || r.URL.Path == prefix+"/.well-known/carddav" { if slices.Contains([]string{
// If normalized request, use normalized host/scheme for redirect "/", prefix + "/",
if publicURL != nil && publicURL.Host != "" { "/.well-known/carddav",
scheme := r.URL.Scheme prefix + "/.well-known/carddav",
if scheme == "" { principalPath,
scheme = "http" strings.TrimSuffix(principalPath, "/"),
}, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") {
if has, _ := be.HasAddressBooks(ctx); has {
w.Header().Add("DAV", "addressbook")
} }
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)
} }
// for caldav and carddav discovery
if slices.Contains([]string{
"/.well-known/caldav",
prefix + "/.well-known/caldav",
"/.well-known/carddav",
prefix + "/.well-known/carddav",
}, r.URL.Path) {
http.Redirect(w, r, fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently)
return return
} }
// needed because color info is not RFC, so regex hack // serve caldav
if strings.Contains(r.URL.Path, "/calendars/") { if strings.Contains(r.URL.Path, "/calendars/") {
if r.Method == "PROPFIND" { if r.Method == "PROPFIND" {
extra.AddColorToCalendarPropfind(r, ctx, caldavHandler, w, be) // 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 { } else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx)) caldavHandler.ServeHTTP(w, r.WithContext(ctx))
} }
// serve carddav
} else if strings.Contains(r.URL.Path, "/addressbooks/") { } else if strings.Contains(r.URL.Path, "/addressbooks/") {
carddavHandler.ServeHTTP(w, r.WithContext(ctx)) carddavHandler.ServeHTTP(w, r.WithContext(ctx))
// catch weird requests
} else { } 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) { if strings.HasSuffix(r.URL.Path, user+"/") || strings.HasSuffix(r.URL.Path, user) || r.URL.Path == "/" || r.URL.Path == prefix+"/" {
// For principal path, use merged discovery handler // For principal path or root, use merged discovery handler
if r.Method == "PROPFIND" { if r.Method == "PROPFIND" {
extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be) extra.HandleDiscoveryPropfind(r, ctx, caldavHandler, w, be)
} else if r.Method == "OPTIONS" {
extra.HandleDiscoveryOptions(r, ctx, caldavHandler, w, be)
} else { } else {
caldavHandler.ServeHTTP(w, r.WithContext(ctx)) 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 { } else {
log.Printf("Not found: %s", r)
http.NotFound(w, r) http.NotFound(w, r)
} }
} }

View File

@@ -1,11 +0,0 @@
{ pkgs ? import <nixpkgs> { } }: let
my-python = pkgs.python312;
python-with-my-packages = my-python.withPackages (p: with p; [
ical
ics
caldav
pyyaml
]);
in pkgs.mkShell {
buildInputs = [ python-with-my-packages ];
}