From 057ba028659a187152dcfff0a0c7d7e201e94010 Mon Sep 17 00:00:00 2001 From: "Lennart J. Kurzweg (Nx2)" Date: Tue, 24 Mar 2026 23:27:14 +0100 Subject: [PATCH] progress --- .ignore | 2 +- config.yaml | 78 ++++++++++++++---------- internal/backend/db.go | 122 ++++++++++++++++++++++++++++---------- internal/config/config.go | 2 + internal/extra/color.go | 107 +++++++++++++++++++++++++++++++++ main.go | 17 ++++-- 6 files changed, 260 insertions(+), 68 deletions(-) create mode 100644 internal/extra/color.go 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.*?>.*?`) + reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)`) + rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?`) + reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK`) + reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?`) + rePropClose := regexp.MustCompile(``) + + 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).*?>.*?`) + 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(``) + 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)