diff --git a/.ignore b/.ignore
index 6aee0f3..e6b7402 100644
--- a/.ignore
+++ b/.ignore
@@ -1,4 +1,4 @@
.direnv
-server.log
+# server.log
mem.go
nxcaldav
diff --git a/config.yaml b/config.yaml
index 42073cd..e19084b 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,42 +1,58 @@
-server:
- bind_address: "0.0.0.0:14243"
- public_url: "http://localhost:8080"
- redaction_text: "[-]"
- default_class: "CONFIDENTIAL"
-
database:
- url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
+ url: postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable
+server:
+ bind_address: 0.0.0.0:14243
+ default_class: CONFIDENTIAL
+ public_url: http://nxc.nx2.site/
+ redaction_text: '[-]'
users:
- - name: "daniel"
- password: "Cyclist-Hypnotize7-Blurb"
+ - name: daniel
+ password: Cyclist-Hypnotize7-Blurb
groups:
- family
- - name: "diane"
- password: "Carve-Unluckily-Reprint1"
+ - name: lennart
+ password: ll
groups:
- family
- - name: "lennart"
- password: "Baton6-Extortion-Monologue"
- groups:
- - family
- - name: "shared"
- password: "Oxidant-Ageless3-Dispersed"
+ - name: shared
+ password: Oxidant-Ageless3-Dispersed
calendars:
- - id: "default"
- owner: "lennart"
- - id: "family"
- owner: "shared"
- access:
- - groups: "family"
- mode: "read-write"
+ - 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'
aggregates:
- - id: "lennart_aggregate"
- owner: "shared"
- sources: [ "default", "family" ]
- access:
- - group: "diane"
- mode: "read-only"
- - ics: "future-only"
+- access:
+ - group: family
+ mode: read-only
+ - ics: future-only
+ id: lennart-aggregat
+ owner: lennart
+ color: '#dd9999'
+ sources:
+ - preservation
+ - effort
+ - experience
+ - leisure
+ - family
+
diff --git a/internal/backend/db.go b/internal/backend/db.go
index 71708a1..4966728 100644
--- a/internal/backend/db.go
+++ b/internal/backend/db.go
@@ -8,6 +8,7 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
"os/exec"
"path"
"strings"
@@ -88,7 +89,8 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
path TEXT UNIQUE NOT NULL,
name TEXT,
- description TEXT
+ description TEXT,
+ color TEXT
)`,
`CREATE TABLE IF NOT EXISTS calendar_access (
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
@@ -200,10 +202,10 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
var calID int
err = tx.QueryRow(ctx, `
- INSERT INTO calendars (owner_id, path, name) VALUES ($1, $2, $3)
- ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name
+ INSERT INTO calendars (owner_id, path, name, color) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color
RETURNING id`,
- ownerID, path, c.ID).Scan(&calID)
+ ownerID, path, c.ID, c.Color).Scan(&calID)
if err != nil {
return err
}
@@ -359,12 +361,12 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
// --- Internal Security & Privacy Helpers ---
// checkAccess verifies if the current user has the required permission for a real calendar.
-// It returns the internal database ID of the calendar if access is granted.
+// It returns the internal database ID of the calendar and the granted mode if access is granted.
// It is called before any read (PROPFIND, GET, REPORT) or write (PUT, DELETE) operation.
-func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, error) {
+func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) {
username, err := b.getUsername(ctx)
if err != nil {
- return 0, err
+ return 0, "", err
}
if !strings.HasSuffix(calendarPath, "/") {
@@ -380,14 +382,14 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
- return 0, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
+ return 0, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
}
- return 0, err
+ return 0, "", err
}
// Owners always have full access
if ownerName == username {
- return calID, nil
+ return calID, "owner", nil
}
// Check the calendar_access table for specific permissions
@@ -398,34 +400,35 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
calID, username).Scan(&mode)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
- return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
+ return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
}
- return 0, err
+ return 0, "", err
}
// Enforce Read-Only mode
if requiredMode == "write" && mode != "read-write" {
- return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
+ return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
}
- return calID, nil
+ return calID, mode, nil
}
// filterCalendar is the privacy enforcement engine of the server.
// It is called every time a calendar object is retrieved from the database
-// for a user who is NOT the owner.
+// 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, 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)
if err != nil {
return nil, err
}
- // Owners see everything unredacted
- if username == ownerName {
+ // Owners and users with read-write access see everything unredacted.
+ // We check both username and mode for robustness.
+ if username == ownerName || mode == "owner" || mode == "read-write" {
return original, nil
}
@@ -499,7 +502,7 @@ func (b *DBBackend) ServePublicICS(w http.ResponseWriter, r *http.Request) {
JOIN users u ON c.owner_id = u.id
WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
if err == nil {
- objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName)
+ objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName, "read")
}
}
@@ -639,7 +642,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
}
username, _ := b.getUsername(ctx)
- log.Printf("[ListCalendarObjects] user=%s path=%s", username, p)
+ log.Printf("[user: %s] ListCalendarObjects %s", username, p)
// Route to virtual aggregate logic if needed
if agg, ok := b.aggregates[p]; ok {
@@ -652,7 +655,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
}
// Normal calendar logic
- calID, err := b.checkAccess(ctx, p, "read")
+ calID, mode, err := b.checkAccess(ctx, p, "read")
if err != nil {
return nil, err
}
@@ -663,10 +666,10 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
return nil, err
}
- return b.listCalendarObjectsRaw(ctx, calID, ownerName)
+ return b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
}
-func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string) ([]caldav.CalendarObject, error) {
+func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string, mode string) ([]caldav.CalendarObject, error) {
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
if err != nil {
return nil, err
@@ -687,7 +690,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
}
// Apply privacy filters (Private/Confidential)
- filteredData, err := b.filterCalendar(ctx, ownerName, calData)
+ filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
if err != nil || filteredData == nil {
continue
}
@@ -701,6 +704,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
// It rewrites paths so the client thinks items are in the virtual directory.
func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
var res []caldav.CalendarObject
+ username, _ := b.getUsername(ctx)
for _, sourceID := range agg.Sources {
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
@@ -715,7 +719,21 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
continue
}
- objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName)
+ // Determine access mode for this user on this source calendar
+ mode := "read"
+ if username == ownerName {
+ mode = "owner"
+ } else if username != "" {
+ err = b.pool.QueryRow(ctx, `
+ SELECT mode FROM calendar_access
+ WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
+ calID, username).Scan(&mode)
+ if err != nil {
+ mode = "read" // Assume read-only if no explicit entry
+ }
+ }
+
+ objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
if err != nil {
continue
}
@@ -746,7 +764,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
// Called during GET, multiget REPORT, or when clicking an event in the client.
func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx)
- log.Printf("[GetCalendarObject] user=%s path=%s", username, p)
+ log.Printf("[user: %s] GetCalendarObject %s", username, p)
dirPath := path.Dir(p) + "/"
fileName := path.Base(p)
@@ -788,7 +806,21 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
return nil, err
}
- filteredData, err := b.filterCalendar(ctx, ownerName, calData)
+ // Determine access mode for this user on this source calendar
+ mode := "read"
+ if username == ownerName {
+ mode = "owner"
+ } else if username != "" {
+ err = b.pool.QueryRow(ctx, `
+ SELECT mode FROM calendar_access
+ WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
+ calID, username).Scan(&mode)
+ if err != nil {
+ mode = "read" // Assume read-only if no explicit entry
+ }
+ }
+
+ filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
if err != nil || filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
}
@@ -814,7 +846,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
dirPath += "/"
}
- calID, err := b.checkAccess(ctx, dirPath, "read")
+ calID, mode, err := b.checkAccess(ctx, dirPath, "read")
if err != nil {
return nil, err
}
@@ -841,7 +873,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
}
// Apply privacy filtering
- filteredData, err := b.filterCalendar(ctx, ownerName, calData)
+ filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
if err != nil || filteredData == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
}
@@ -859,7 +891,7 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
}
- calID, err := b.checkAccess(ctx, dirPath, "write")
+ calID, _, err := b.checkAccess(ctx, dirPath, "write")
if err != nil {
return nil, err
}
@@ -895,7 +927,7 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
}
- calID, err := b.checkAccess(ctx, dirPath, "write")
+ calID, _, err := b.checkAccess(ctx, dirPath, "write")
if err != nil {
return err
}
@@ -915,6 +947,32 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
// we use it to enforce privacy rules for the initial report.
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
username, _ := b.getUsername(ctx)
- log.Printf("[QueryCalendarObjects] user=%s path=%s", username, path_)
+ log.Printf("[user: %s] QueryCalendarObjects %s", username, path_)
return b.ListCalendarObjects(ctx, path_, nil)
}
+
+func (b *DBBackend) GetColor(ctx context.Context, p string) string {
+ // If p is a full URL, extract the path
+ if strings.HasPrefix(p, "http") {
+ if u, err := url.Parse(p); err == nil {
+ p = u.Path
+ }
+ }
+
+ if !strings.HasSuffix(p, "/") {
+ p += "/"
+ }
+
+ // Check if it's an aggregate
+ if agg, ok := b.aggregates[p]; ok {
+ return agg.Color
+ }
+
+ // Check if it's a real calendar
+ var color string
+ err := b.pool.QueryRow(ctx, "SELECT color FROM calendars WHERE path = $1", p).Scan(&color)
+ if err == nil {
+ return color
+ }
+ return ""
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index e0b51cd..0aa0756 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -20,12 +20,14 @@ type Access struct {
type Calendar struct {
ID string `yaml:"id"`
Owner string `yaml:"owner"`
+ Color string `yaml:"color,omitempty"`
Access []Access `yaml:"access,omitempty"`
}
type Aggregate struct {
ID string `yaml:"id"`
Owner string `yaml:"owner"`
+ Color string `yaml:"color,omitempty"`
Sources []string `yaml:"sources"` // Calendar IDs
Access []Access `yaml:"access,omitempty"`
}
diff --git a/internal/extra/color.go b/internal/extra/color.go
new file mode 100644
index 0000000..f33bf7b
--- /dev/null
+++ b/internal/extra/color.go
@@ -0,0 +1,107 @@
+package extra
+
+import (
+ "bytes"
+ "strings"
+ "fmt"
+ "context"
+ "io"
+ "net/http"
+ "nxcaldav/internal/backend"
+ "regexp"
+ "time"
+)
+
+type responseWriter struct {
+ http.ResponseWriter
+ buffer *bytes.Buffer
+ status int
+}
+
+func (rw *responseWriter) Write(b []byte) (int, error) {
+ return rw.buffer.Write(b)
+}
+
+func (rw *responseWriter) WriteHeader(status int) {
+ rw.status = status
+}
+
+func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
+ reqBody, _ := io.ReadAll(r.Body)
+ r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
+
+ buf := &bytes.Buffer{}
+ rw := &responseWriter{w, buf, http.StatusOK}
+ handler.ServeHTTP(rw, r.WithContext(ctx))
+
+ body := buf.Bytes()
+
+ // 1. Add namespaces to the root multistatus tag
+ reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
+ body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:ICAL="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/"`))
+
+ // 2. Response processing
+ reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?[a-zA-Z0-9]*:?response>`)
+ reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)[a-zA-Z0-9]*:?href>`)
+ rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?[a-zA-Z0-9]*:?propstat>`)
+ reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK[a-zA-Z0-9]*:?status>`)
+ reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?[a-zA-Z0-9]*:?prop>`)
+ rePropClose := regexp.MustCompile(`[a-zA-Z0-9]*:?prop>`)
+
+ body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte {
+ hrefMatch := reHref.FindSubmatch(resp)
+ if len(hrefMatch) < 2 {
+ return resp
+ }
+ href := string(hrefMatch[1])
+ color := be.GetColor(r.Context(), href)
+ if color == "" {
+ return resp
+ }
+
+ // 1. Strip any existing conflicting tags that might be in 404 blocks
+ 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(""))
+
+ fullColor := strings.ToLower(color)
+ if len(fullColor) == 7 && strings.HasPrefix(fullColor, "#") {
+ fullColor += "ff"
+ }
+ // Prepare the properties to inject
+ props := fmt.Sprintf("%s", fullColor)
+ props += fmt.Sprintf("%s", fullColor)
+ props += "0"
+ props += fmt.Sprintf("\"%d\"", time.Now().Unix())
+
+ // 2. Try to inject into an existing 200 OK propstat
+ has200 := false
+ resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
+ if reStatusOk.Match(ps) {
+ has200 = true
+ return reProp.ReplaceAllFunc(ps, func(prop []byte) []byte {
+ return rePropClose.ReplaceAllFunc(prop, func(closeTag []byte) []byte {
+ return append([]byte(props), closeTag...)
+ })
+ })
+ }
+ return ps
+ })
+
+ // 3. If no 200 OK propstat was found, create one!
+ if !has200 {
+ newPropstat := fmt.Sprintf("%sHTTP/1.1 200 OK", props)
+ reResponseClose := regexp.MustCompile(`[a-zA-Z0-9]*:?response>`)
+ resp = reResponseClose.ReplaceAllFunc(resp, func(closeTag []byte) []byte {
+ return append([]byte(newPropstat), closeTag...)
+ })
+ }
+
+ return resp
+ })
+
+ // Headers are already set in h.ResponseWriter.Header() by caldav.Handler
+ // But Content-Length might have changed
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ w.WriteHeader(rw.status)
+ w.Write(body)
+}
diff --git a/main.go b/main.go
index 86e86a6..b445bbe 100644
--- a/main.go
+++ b/main.go
@@ -12,9 +12,12 @@ import (
"github.com/emersion/go-webdav/caldav"
"nxcaldav/internal/backend"
+ "nxcaldav/internal/extra"
"nxcaldav/internal/config"
)
+
+
func main() {
path := "config.yaml";
if len(os.Args) == 3 {
@@ -61,12 +64,14 @@ func main() {
}
}
+ // public ics access
prefix := cfg.Server.BasePath()
if strings.HasPrefix(r.URL.Path, prefix+"/public/") {
be.ServePublicICS(w, r)
return
}
+ // caldav access needs auth
user, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`)
@@ -88,13 +93,12 @@ func main() {
return
}
- log.Printf("%s %s (user: %s)", r.Method, r.URL.Path, user)
- prefix = cfg.Server.BasePath()
+ log.Printf("[user: %s] %s %s ", user, r.Method, r.URL.Path)
principalPath := prefix + fmt.Sprintf("/%s/", user)
ctx := context.WithValue(r.Context(), "principal", principalPath)
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 normalized request, use normalized host/scheme for redirect
if publicURL != nil && publicURL.Host != "" {
scheme := r.URL.Scheme
if scheme == "" {
@@ -109,7 +113,12 @@ func main() {
}
- handler.ServeHTTP(w, r.WithContext(ctx))
+ // needed because color info is not RFC, so regex hack
+ if r.Method == "PROPFIND" {
+ extra.AddColorToCalendarPropfind(r, ctx, handler, w, be)
+ } else {
+ handler.ServeHTTP(w, r.WithContext(ctx))
+ }
})
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress)