10 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
11 changed files with 138 additions and 135 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

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os/exec"
"path" "path"
"slices" "slices"
"strings" "strings"
@@ -103,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,
@@ -111,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,
@@ -140,16 +139,9 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
} }
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 != "" { if err != nil {
cmd := exec.Command("sh", "-c", u.PasswordCmd) return "", fmt.Errorf("failed to resolve password for %s: %v", u.Name, err)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to run password command 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.
@@ -237,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]...)
} }
@@ -265,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,
@@ -307,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]...)
} }
@@ -358,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]...)
} }
@@ -773,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
} }
@@ -805,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")
@@ -836,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 {
@@ -851,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"))
} }
@@ -1473,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

@@ -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"
@@ -10,11 +12,12 @@ import (
) )
type Access struct { type Access struct {
User string `yaml:"user,omitempty"` User string `yaml:"user,omitempty"`
Group string `yaml:"group,omitempty"` Users []string `yaml:"users,omitempty"`
Groups string `yaml:"groups,omitempty"` Group string `yaml:"group,omitempty"`
Mode string `yaml:"mode"` // "read-only" or "read-write" Groups string `yaml:"groups,omitempty"`
ICS string `yaml:"ics,omitempty"` Mode string `yaml:"mode"` // "read-only" or "read-write"
ICS string `yaml:"ics,omitempty"`
} }
type Calendar struct { type Calendar struct {
@@ -64,10 +67,11 @@ func (s ServerConfig) BasePath() string {
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 {
@@ -79,6 +83,18 @@ 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() { func (c *Config) setDefaults() {
if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" } if c.Server.BindAddress == "" { c.Server.BindAddress = ":8080" }
if c.Server.Redaction == "" { c.Server.Redaction = "Busy" } if c.Server.Redaction == "" { c.Server.Redaction = "Busy" }

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
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`) if !bytes.Contains(body, []byte("xmlns:ICAL=")) {
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/"`)) reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
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 == "" {

BIN
main

Binary file not shown.

25
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/") {
w.Header().Add("DAV", "addressbook") if has, _ := be.HasAddressBooks(ctx); has {
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)
} }
} }

View File

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