This commit is contained in:
Lennart J. Kurzweg (Nx2)
2026-03-21 02:39:09 +01:00
commit 41e36a4545
15 changed files with 1247 additions and 0 deletions

318
internal/backend/mem.go Normal file
View File

@@ -0,0 +1,318 @@
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)
}