This commit is contained in:
Lennart J. Kurzweg (Nx2)
2026-03-23 22:06:12 +01:00
parent d55fff9f4a
commit b5a3a63dde
6 changed files with 80 additions and 112 deletions

View File

@@ -1,6 +1,6 @@
server:
bind_address: "0.0.0.0:14243"
public_url: "http://nxc.nx2.site"
public_url: "http://localhost:8080"
redaction_text: "[-]"
default_class: "CONFIDENTIAL"
@@ -9,31 +9,19 @@ database:
users:
- name: "daniel"
password: "123"
password: "Cyclist-Hypnotize7-Blurb"
groups:
- family
- parent
- name: "diane"
password: "123"
groups:
- family
- parent
- name: "georg"
password: "123"
password: "Carve-Unluckily-Reprint1"
groups:
- family
- name: "lennart"
password: "123"
password: "Baton6-Extortion-Monologue"
groups:
- family
- name: "tessa"
password: "123"
groups:
- family
- name: "testuser"
password: "123"
- name: "shared"
password: "123"
password: "Oxidant-Ageless3-Dispersed"
calendars:
- id: "default"
@@ -43,17 +31,12 @@ calendars:
access:
- groups: "family"
mode: "read-write"
- id: "tessas-inbox"
owner: "tessa"
access:
- group: "parent"
mode: "read-write"
aggregates:
- id: "lennart_aggregate"
owner: "lennart"
owner: "shared"
sources: [ "default", "family" ]
access:
- group: "family"
- group: "diane"
mode: "read-only"
- ics: "future-only"

View File

@@ -32,6 +32,7 @@ type publicInfo struct {
// access control, privacy redaction, and aggregate (virtual) calendars.
type DBBackend struct {
pool *pgxpool.Pool // Connection pool to PostgreSQL
prefix string // Public URL base path prefix
redactionText string // Text used to hide confidential event details (e.g. "[REDACED]")
defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL")
aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions
@@ -51,6 +52,7 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
b := &DBBackend{
pool: pool,
prefix: cfg.Server.BasePath(),
redactionText: cfg.Server.Redaction,
defaultClass: cfg.Server.DefaultClass,
aggregates: make(map[string]*config.Aggregate),
@@ -163,6 +165,8 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
b.userAggs = make(map[string][]string)
b.publicAccess = make(map[string]publicInfo)
prefix := cfg.Server.BasePath()
// --- Phase 1: User Sync ---
for _, u := range cfg.Users {
configUserNames[u.Name] = true
@@ -185,7 +189,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
// --- Phase 2: Calendar & Access Sync ---
for _, c := range cfg.Calendars {
path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
path := prefix + fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
configCalendarPaths[path] = true
var ownerID int
@@ -243,7 +247,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
// Check for public access (ICS)
for _, a := range c.Access {
if a.ICS != "" {
pubPath := fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID)
pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", c.Owner, c.ID)
b.publicAccess[pubPath] = publicInfo{
InternalPath: path,
Mode: a.ICS,
@@ -255,7 +259,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
// --- Phase 3: Aggregate Setup ---
// Aggregates are virtual, so we only track them in memory for routing.
for _, agg := range cfg.Aggregates {
p_ := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, agg.ID)
p_ := prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, agg.ID)
if configCalendarPaths[p_] {
return fmt.Errorf("aggregate %s collides with real calendar path", p_)
}
@@ -267,7 +271,7 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
aggAccess[agg.Owner] = true
for _, a := range agg.Access {
if a.ICS != "" {
pubPath := fmt.Sprintf("/public/%s/%s.ics", agg.Owner, agg.ID)
pubPath := prefix + fmt.Sprintf("/public/%s/%s.ics", agg.Owner, agg.ID)
b.publicAccess[pubPath] = publicInfo{
InternalPath: p_,
Mode: a.ICS,
@@ -548,7 +552,7 @@ func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
return fmt.Sprintf("/%s/calendars/", username), nil
return b.prefix + fmt.Sprintf("/%s/calendars/", username), nil
}
// ListCalendars returns all calendars (real and virtual) the user has access to.
@@ -699,7 +703,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
var res []caldav.CalendarObject
for _, sourceID := range agg.Sources {
sourcePath := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int
var ownerName string
@@ -763,7 +767,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
sourceID := parts[0]
realFileName := parts[1]
sourcePath := fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
var calID int
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)

View File

@@ -1,8 +1,10 @@
package config
import (
"net/url"
"os"
"slices"
"strings"
"gopkg.in/yaml.v3"
)
@@ -46,6 +48,14 @@ type ServerConfig struct {
DefaultClass string `yaml:"default_class"`
}
func (s ServerConfig) BasePath() string {
u, err := url.Parse(s.PublicURL)
if err != nil {
return ""
}
return strings.TrimSuffix(u.Path, "/")
}
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`

55
main.go
View File

@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"log"
"os"
"net/http"
"net/url"
"strings"
"time"
@@ -14,7 +16,13 @@ import (
)
func main() {
cfg, err := config.Load("config.yaml")
path := "config.yaml";
if len(os.Args) != 0 {
if os.Args[1] == "-c" {
path = os.Args[2]
}
}
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
@@ -27,8 +35,34 @@ func main() {
handler := &caldav.Handler{Backend: be}
publicURL, _ := url.Parse(cfg.Server.PublicURL)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/public/") {
// Proxy-aware normalization:
if publicURL != nil && publicURL.Host != "" {
r.Host = publicURL.Host
r.URL.Host = publicURL.Host
// Detect scheme: prioritize X-Forwarded-Proto, then PublicURL
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)
if dest := r.Header.Get("Destination"); dest != "" {
destURL, err := url.Parse(dest)
if err == nil {
destURL.Host = publicURL.Host
destURL.Scheme = scheme
r.Header.Set("Destination", destURL.String())
}
}
}
prefix := cfg.Server.BasePath()
if strings.HasPrefix(r.URL.Path, prefix+"/public/") {
be.ServePublicICS(w, r)
return
}
@@ -55,15 +89,26 @@ func main() {
}
log.Printf("%s %s (user: %s)", r.Method, r.URL.Path, user)
principalPath := fmt.Sprintf("/%s/", user)
prefix = cfg.Server.BasePath()
principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath)
if r.URL.Path == "/.well-known/caldav" {
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" {
// If we normalized the request, use the normalized host/scheme for the redirect
if publicURL != nil && publicURL.Host != "" {
scheme := r.URL.Scheme
if scheme == "" {
scheme = "http"
}
target := fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath)
http.Redirect(w, r, target, http.StatusMovedPermanently)
} else {
http.Redirect(w, r, principalPath, http.StatusMovedPermanently)
}
return
}
handler.ServeHTTP(w, r.WithContext(ctx))
})

35
test.py
View File

@@ -1,35 +0,0 @@
import os
import yaml
from caldav import DAVClient
from ics import Calendar
from datetime import datetime
if __name__ == "__main__":
with open("config.yaml") as f:
config = yaml.safe_load(f)
url = "http://localhost:8080/"
# Diane checks the aggregate 'lennart' calendar
diane_client = DAVClient(url, username="diane", password="123")
d_principal = diane_client.principal()
print("\n--- Diane viewing Aggregate 'lennart' calendar ---")
agg_path = "/lennart/calendars/lennart/"
lennart_agg = next(c for c in d_principal.calendars() if agg_path in c.url.path)
events = lennart_agg.events()
print(f"Events found in aggregate: {len(events)}")
for e in events:
print(f" - Path: {e.url.path}")
if not e.url.path.startswith(agg_path):
print(f" ERROR: Path {e.url.path} is NOT under the aggregate {agg_path}!")
# Test individual GET (GetCalendarObject)
try:
data = e.data
c = Calendar(data)
for ev in c.events:
print(f" Fetched: {ev.name} (UID: {ev.uid})")
except Exception as err:
print(f" ERROR fetching individual item: {err}")

View File

@@ -1,39 +0,0 @@
import psycopg2
import sys
def check_db():
try:
conn = psycopg2.connect("postgres://nxcaldav@localhost:5432/nxcaldav")
cur = conn.cursor()
print("--- Users ---")
cur.execute("SELECT id, name FROM users")
users = cur.fetchall()
for u in users:
print(u)
print("\n--- Calendars ---")
cur.execute("SELECT id, path, owner_id FROM calendars")
cals = cur.fetchall()
for c in cals:
print(c)
print("\n--- Calendar Access (Diane) ---")
cur.execute("""
SELECT c.path, ca.mode
FROM calendar_access ca
JOIN calendars c ON ca.calendar_id = c.id
JOIN users u ON ca.user_id = u.id
WHERE u.name = 'diane'
""")
access = cur.fetchall()
for a in access:
print(a)
cur.close()
conn.close()
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
check_db()