Files
nidus/internal/caldav/backend.go
T
2026-04-23 21:56:59 +02:00

283 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package caldav
import (
"context"
"fmt"
"log/slog"
"net/http"
"path"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/store"
)
// Backend implements caldav.Backend using a filesystem store.
type Backend struct {
cfg *config.Config
store *store.Store
logger *slog.Logger
}
// NewBackend creates a CalDAV backend.
func NewBackend(cfg *config.Config, st *store.Store, logger *slog.Logger) *Backend {
return &Backend{cfg: cfg, store: st, logger: logger}
}
// NewHandler returns an http.Handler for the /cal/ prefix.
func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.Handler {
b := NewBackend(cfg, st, logger)
return &caldav.Handler{Backend: b}
}
// -------- caldav.Backend interface --------
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return principalPath(p.Username), nil
}
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return calHomePath(p.Username), nil
}
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
p := auth.FromContext(ctx)
if p == nil {
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user, ok := b.cfg.Users[p.Username]
if !ok {
return nil, fmt.Errorf("user not found")
}
var cals []caldav.Calendar
for _, name := range user.Calendars {
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
continue
}
cals = append(cals, b.calendarMeta(p.Username, name))
}
// Also include any extra calendars that exist on disk but aren't in config
disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool)
for _, n := range user.Calendars {
configured["cal-"+n] = true
}
for _, dir := range disk {
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
name := strings.TrimPrefix(dir, "cal-")
cals = append(cals, b.calendarMeta(p.Username, name))
}
}
return cals, nil
}
func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) {
user, name, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
if _, err := b.store.GetCollection(user, "cal-"+name); err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal := b.calendarMeta(user, name)
return &cal, nil
}
func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
data, err := b.store.GetObject(user, "cal-"+calName, objID)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
return b.decodeObject(objPath, data)
}
func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
user, calName, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
ids, err := b.store.ListObjects(user, "cal-"+calName)
if err != nil {
return nil, err
}
var objs []caldav.CalendarObject
for _, id := range ids {
data, err := b.store.GetObject(user, "cal-"+calName, id)
if err != nil {
continue
}
obj, err := b.decodeObject(calObjectPath(user, calName, id), data)
if err != nil {
b.logger.Warn("decoding calendar object", "id", id, "error", err)
continue
}
objs = append(objs, *obj)
}
return objs, nil
}
func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
// List all and filter sufficient for small collections.
all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest)
if err != nil {
return nil, err
}
return caldav.Filter(query, all)
}
func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
p := auth.FromContext(ctx)
if p == nil {
return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
// Derive collection name from the trailing path segment.
name := path.Base(strings.TrimSuffix(calendar.Path, "/"))
return b.store.EnsureCollection(p.Username, "cal-"+name)
}
func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
user, name, err := b.parseCalPath(ctx, calPath)
if err != nil {
return err
}
return b.store.DeleteCollection(user, "cal-"+name)
}
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
var buf strings.Builder
enc := ical.NewEncoder(&buf)
if err := enc.Encode(calendar); err != nil {
return nil, fmt.Errorf("encoding calendar: %w", err)
}
data := []byte(buf.String())
if err := b.store.PutObject(user, "cal-"+calName, objID, data); err != nil {
return nil, fmt.Errorf("storing calendar object: %w", err)
}
return b.decodeObject(objPath, data)
}
func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) error {
user, calName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return err
}
if err := b.store.DeleteObject(user, "cal-"+calName, objID); err != nil {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
return nil
}
// -------- helpers --------
func (b *Backend) calendarMeta(user, name string) caldav.Calendar {
return caldav.Calendar{
Path: calHomePath(user) + name + "/",
Name: name,
Description: fmt.Sprintf("%s's %s calendar", user, name),
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
MaxResourceSize: 10 * 1024 * 1024, // 10 MiB
}
}
func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) {
cal, err := ical.NewDecoder(strings.NewReader(string(data))).Decode()
if err != nil {
return nil, fmt.Errorf("decoding ical: %w", err)
}
etag := fmt.Sprintf(`"%x"`, hashBytes(data))
return &caldav.CalendarObject{
Path: objPath,
ModTime: time.Now(),
ContentLength: int64(len(data)),
ETag: etag,
Data: cal,
}, nil
}
func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calName string, err error) {
p := auth.FromContext(ctx)
if p == nil {
return "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user = p.Username
parts := strings.Split(strings.Trim(calPath, "/"), "/")
// expected: cal/<user>/<calname>/
if len(parts) < 3 {
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path"))
}
calName = parts[2]
return user, calName, nil
}
func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calName, objID string, err error) {
p := auth.FromContext(ctx)
if p == nil {
return "", "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
user = p.Username
parts := strings.Split(strings.Trim(objPath, "/"), "/")
// expected: cal/<user>/<calname>/<objid>
if len(parts) < 4 {
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
}
calName = parts[2]
objID = path.Base(objPath)
return user, calName, objID, nil
}
func principalPath(user string) string {
return fmt.Sprintf("/principals/%s/", user)
}
func calHomePath(user string) string {
return fmt.Sprintf("/cal/%s/", user)
}
func calObjectPath(user, calName, objID string) string {
return fmt.Sprintf("/cal/%s/%s/%s", user, calName, objID)
}
func hashBytes(data []byte) uint64 {
var h uint64 = 14695981039346656037
for _, b := range data {
h ^= uint64(b)
h *= 1099511628211
}
return h
}