6 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
8 changed files with 104 additions and 72 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.direnv .direnv
server.log server.log
shell.nix
mem.go mem.go
nxcaldav nxcaldav
in/ in/

View File

@@ -14,34 +14,10 @@ smtp:
password_cmd: echo "Vastly-Wrinkle9-Corsage" password_cmd: echo "Vastly-Wrinkle9-Corsage"
users: users:
- name: alice
password: 123
groups:
- family
- name: bob
password: abc
groups:
- family
calendars: calendars:
- id: test
owner: alice
color: '#F6F5F4'
- id: family
owner: shared
color: '#999999'
access:
- group: family
mode: read-write
address_books: address_books:
- id: contacts
owner: alice
- id: family
owner: bob
access:
- group: family
mode: read-write
aggregates: aggregates:

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env python
import os import os
import argparse import argparse
import psycopg2 import psycopg2

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env python
import os import os
import argparse import argparse
import psycopg2 import psycopg2

View File

@@ -102,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,
@@ -110,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,
@@ -229,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]...)
} }
@@ -257,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,
@@ -299,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]...)
} }
@@ -350,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]...)
} }
@@ -765,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
} }
@@ -797,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")
@@ -828,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 {
@@ -843,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"))
} }
@@ -1465,3 +1469,18 @@ func (b *DBBackend) DeleteAddressObject(ctx context.Context, p string) error {
func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { func (b *DBBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
return b.ListAddressObjects(ctx, p, nil) return b.ListAddressObjects(ctx, p, nil)
} }
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

@@ -13,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"

View File

@@ -39,9 +39,19 @@ func InjectColor(r *http.Request, ctx context.Context, handler http.Handler, w h
body := buf.Bytes() body := buf.Bytes()
// this models after the Radicale Response, largely AI code // this models after the Radicale Response, largely AI code
// 1. Add namespaces to the root multistatus tag // 1. Add namespaces to the root multistatus tag 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>`)
@@ -62,7 +72,7 @@ func InjectColor(r *http.Request, ctx context.Context, handler http.Handler, w h
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(""))
@@ -107,10 +117,23 @@ func InjectColor(r *http.Request, ctx context.Context, handler http.Handler, w h
w.Write(body) w.Write(body)
} }
// modify caledar probfind func HandleDiscoveryOptions(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
// buf := &bytes.Buffer{}
// this again modeled after the Radicale response rw := &responseWriter{w, buf, http.StatusOK}
// Largly AI generated agian 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))
@@ -121,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>`)
@@ -139,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)>`)
@@ -152,11 +182,13 @@ 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 == "" {

23
main.go
View File

@@ -106,20 +106,25 @@ func main() {
principalPath := prefix + fmt.Sprintf("/%s/", user) principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath) ctx := context.WithValue(r.Context(), "principal", principalPath)
// set header for carddav // set header for carddav if user has address books
if slices.Contains([]string{ if slices.Contains([]string{
"/", prefix + "/",
"/.well-known/carddav", "/.well-known/carddav",
prefix + "/.well-known/carddav", prefix + "/.well-known/carddav",
principalPath, principalPath,
strings.TrimSuffix(principalPath, "/"), strings.TrimSuffix(principalPath, "/"),
}, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") { }, r.URL.Path) || strings.Contains(r.URL.Path, "/addressbooks/") {
if has, _ := be.HasAddressBooks(ctx); has {
w.Header().Add("DAV", "addressbook") w.Header().Add("DAV", "addressbook")
} }
}
// for caldav discovery // for caldav and carddav discovery
if slices.Contains([]string{ if slices.Contains([]string{
"/.well-known/caldav", "/.well-known/caldav",
prefix + "/.well-known/caldav", prefix + "/.well-known/caldav",
"/.well-known/carddav",
prefix + "/.well-known/carddav",
}, r.URL.Path) { }, r.URL.Path) {
http.Redirect(w, r, fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently) http.Redirect(w, r, fmt.Sprintf("%s://%s%s", scheme, r.Host, principalPath), http.StatusMovedPermanently)
return return
@@ -142,21 +147,17 @@ func main() {
// catch weird requests // catch weird requests
} else { } else {
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)
} }
} }