package backend import ( "context" "errors" "fmt" "net/http" "strings" "sync" "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "nxcaldav/internal/config" ) type MemBackend struct { sync.RWMutex allCalendars map[string]*caldav.Calendar objects map[string]map[string]*caldav.CalendarObject userCalendars map[string][]string calendarOwner map[string]string calendarAccess map[string]map[string]string } func NewMemBackend(cfg *config.Config) *MemBackend { b := &MemBackend{ allCalendars: make(map[string]*caldav.Calendar), objects: make(map[string]map[string]*caldav.CalendarObject), userCalendars: make(map[string][]string), calendarOwner: make(map[string]string), calendarAccess: make(map[string]map[string]string), } for _, c := range cfg.Calendars { path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID) cal := &caldav.Calendar{ Path: path, Name: c.ID, SupportedComponentSet: []string{"VEVENT", "VTODO"}, } b.allCalendars[path] = cal b.calendarOwner[path] = c.Owner b.userCalendars[c.Owner] = append(b.userCalendars[c.Owner], path) accessMap := make(map[string]string) for _, a := range c.Access { accessMap[a.User] = a.Mode b.userCalendars[a.User] = append(b.userCalendars[a.User], path) } b.calendarAccess[path] = accessMap } return b } func (b *MemBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { principal, ok := ctx.Value("principal").(string) if !ok { return "", errors.New("no principal in context") } return principal, nil } func (b *MemBackend) getUsername(ctx context.Context) (string, error) { principal, err := b.CurrentUserPrincipal(ctx) if err != nil { return "", err } return strings.Trim(principal, "/"), nil } func (b *MemBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) error { username, err := b.getUsername(ctx) if err != nil { return err } if !strings.HasSuffix(calendarPath, "/") { calendarPath += "/" } owner := b.calendarOwner[calendarPath] if owner == username { return nil } mode, ok := b.calendarAccess[calendarPath][username] if !ok { return webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied")) } if requiredMode == "write" && mode != "read-write" { return webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access")) } return nil } func (b *MemBackend) filterCalendar(ctx context.Context, calendarPath string, original *ical.Calendar) (*ical.Calendar, error) { username, err := b.getUsername(ctx) if err != nil { return nil, err } if !strings.HasSuffix(calendarPath, "/") { calendarPath += "/" } owner := b.calendarOwner[calendarPath] if username == owner { return original, nil } filtered := ical.NewCalendar() filtered.Props = original.Props for _, child := range original.Children { if child.Name != "VEVENT" && child.Name != "VTODO" { filtered.Children = append(filtered.Children, child) continue } class := "PUBLIC" if prop := child.Props.Get("CLASS"); prop != nil { class = strings.ToUpper(prop.Value) } switch class { case "PRIVATE": continue case "CONFIDENTIAL": redacted := ical.NewComponent(child.Name) propsToKeep := []string{ "UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS", "DUE", "COMPLETED", "STATUS", "PRIORITY", "PERCENT-COMPLETE", } for _, pName := range propsToKeep { if props, ok := child.Props[pName]; ok { redacted.Props[pName] = props } } redacted.Props.SetText("SUMMARY", "Busy") filtered.Children = append(filtered.Children, redacted) default: filtered.Children = append(filtered.Children, child) } } if len(filtered.Children) == 0 { return nil, nil } return filtered, nil } // CalDAV Backend implementation func (b *MemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { username, err := b.getUsername(ctx) if err != nil { return "", err } return fmt.Sprintf("/%s/calendars/", username), nil } func (b *MemBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { username, err := b.getUsername(ctx) if err != nil { return nil, err } b.RLock() defer b.RUnlock() paths := b.userCalendars[username] res := make([]caldav.Calendar, 0, len(paths)) for _, p := range paths { if cal, ok := b.allCalendars[p]; ok { res = append(res, *cal) } } return res, nil } func (b *MemBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { b.RLock() defer b.RUnlock() if !strings.HasSuffix(path, "/") { path += "/" } if cal, ok := b.allCalendars[path]; ok { return cal, nil } return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) } func (b *MemBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config")) } func (b *MemBackend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { if err := b.checkAccess(ctx, path, "read"); err != nil { return nil, err } b.RLock() defer b.RUnlock() if !strings.HasSuffix(path, "/") { path += "/" } objs, ok := b.objects[path] if !ok { return []caldav.CalendarObject{}, nil } res := make([]caldav.CalendarObject, 0, len(objs)) for _, obj := range objs { filteredData, err := b.filterCalendar(ctx, path, obj.Data) if err != nil { return nil, err } if filteredData == nil { continue } newObj := *obj newObj.Data = filteredData res = append(res, newObj) } return res, nil } func (b *MemBackend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { lastSlash := strings.LastIndex(path, "/") if lastSlash == -1 { return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) } parentPath := path[:lastSlash+1] if err := b.checkAccess(ctx, parentPath, "read"); err != nil { return nil, err } b.RLock() defer b.RUnlock() if objs, ok := b.objects[parentPath]; ok { if obj, ok := objs[path]; ok { filteredData, err := b.filterCalendar(ctx, parentPath, obj.Data) if err != nil { return nil, err } if filteredData == nil { return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } newObj := *obj newObj.Data = filteredData return &newObj, nil } } return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } func (b *MemBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { lastSlash := strings.LastIndex(path, "/") if lastSlash == -1 { return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) } parentPath := path[:lastSlash+1] if err := b.checkAccess(ctx, parentPath, "write"); err != nil { return nil, err } b.Lock() defer b.Unlock() obj := &caldav.CalendarObject{ Path: path, Data: calendar, ETag: fmt.Sprintf(`"%d"`, len(calendar.Events())), } if _, ok := b.objects[parentPath]; !ok { b.objects[parentPath] = make(map[string]*caldav.CalendarObject) } b.objects[parentPath][path] = obj return obj, nil } func (b *MemBackend) DeleteCalendarObject(ctx context.Context, path string) error { lastSlash := strings.LastIndex(path, "/") if lastSlash == -1 { return webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path")) } parentPath := path[:lastSlash+1] if err := b.checkAccess(ctx, parentPath, "write"); err != nil { return err } b.Lock() defer b.Unlock() if objs, ok := b.objects[parentPath]; ok { delete(objs, path) return nil } return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found")) } func (b *MemBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { return b.ListCalendarObjects(ctx, path, nil) }