Files
nidus/internal/caldav/backend.go
T
arnefandCopilot daa51d62b1 Add calendar/address-book sharing backend
Introduce internal/db, a small SQLite-backed store (pure-Go
modernc.org/sqlite, no CGO) at <data_dir>/nidus.db holding
calendar_shares and addressbook_shares grant tables (owner, resource
name, shared-with user, read/write permission). This is the first step
towards user management and a web UI: a real datastore that a future
admin CLI/UI can build on, instead of the static config.yaml.

Wire sharing into the CalDAV/CardDAV backends:
- ListCalendars/ListAddressBooks now also include resources shared with
  the requesting user, exposed under the synthetic local name
  "<owner>~<name>" in the grantee's own home-set — no separate account,
  no data copying, the object still physically lives under the owner's
  store.Store namespace.
- All read paths (Get/List/QueryCalendarObjects, address book
  equivalents) resolve the synthetic name back to (owner, real name) and
  require any share (read or write) to exist.
- All write paths (Put/Delete object, DeleteCalendar/AddressBook)
  additionally require a write-permission share; read-only shares get a
  403 Forbidden.
- CreateCalendar/CreateAddressBook remain scoped to the acting user's own
  namespace — sharing an existing collection is done via ShareCalendar/
  ShareAddressBook, not by creating one directly in someone else's name.

Add internal/db/shares_test.go (grant/lookup/update/unshare/list
semantics) and internal/{caldav,carddav}/backend_test.go (shared
calendar/address book visibility, write permission enforcement,
unauthorized access rejection). Update README (features, new "Sharing
calendars and address books" section, project layout, dependencies) and
copilot-instructions.md to document the new package and sharing model.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-18 20:10:31 +02:00

383 lines
13 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/db"
"github.com/yourusername/caldav-server/internal/store"
)
// sharedNameSep separates the owner from the calendar name in the
// synthetic name used for calendars shared with another user, e.g.
// "alice~work" for alice's "work" calendar as seen by whoever it was
// shared with. It must not collide with characters allowed in real
// calendar names (validated wherever calendar names are taken as input).
const sharedNameSep = "~"
// Backend implements caldav.Backend using a filesystem store.
type Backend struct {
cfg *config.Config
store *store.Store
dbase *db.DB // may be nil if sharing is not configured
logger *slog.Logger
}
// NewBackend creates a CalDAV backend. dbase may be nil, in which case
// calendar sharing is disabled (only a user's own calendars are visible).
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend {
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger}
}
// NewHandler returns an http.Handler for the /cal/ prefix.
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
b := NewBackend(cfg, st, dbase, logger)
return &caldav.Handler{Backend: b}
}
// -------- caldav.Backend interface --------
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
// Must resolve to a path served by this same handler (i.e. under /cal/)
// at exactly one path segment of depth, since go-webdav's caldav server
// classifies resources purely by path depth relative to Handler.Prefix:
// depth 1 = principal, depth 2 = home-set, depth 3 = calendar, depth 4 =
// calendar object. A /principals/<user>/ path would never be reached
// (nothing is mounted there) and would break discovery.
return calPrincipalPath(), nil
}
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return calHomePath(), 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, 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, name))
}
}
// Include calendars other users have shared with this one.
if b.dbase != nil {
shares, err := b.dbase.CalendarsSharedWith(p.Username)
if err != nil {
b.logger.Warn("listing shared calendars", "error", err)
}
for _, sh := range shares {
if _, err := b.store.GetCollection(sh.Owner, "cal-"+sh.CalendarName); err != nil {
continue // owner's calendar no longer exists
}
localName := sharedCalendarName(sh.Owner, sh.CalendarName)
cals = append(cals, b.calendarMeta(sh.Owner, sh.CalendarName, localName))
}
}
return cals, nil
}
func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) {
requester, localName, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
}
if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal := b.calendarMeta(owner, realName, localName)
return &cal, nil
}
func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
}
data, err := b.store.GetObject(owner, "cal-"+realName, 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) {
requester, localName, err := b.parseCalPath(ctx, calPath)
if err != nil {
return nil, err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
}
ids, err := b.store.ListObjects(owner, "cal-"+realName)
if err != nil {
return nil, err
}
var objs []caldav.CalendarObject
for _, id := range ids {
data, err := b.store.GetObject(owner, "cal-"+realName, id)
if err != nil {
continue
}
obj, err := b.decodeObject(calObjectPath(localName, 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. Calendars are
// always created under the acting user's own namespace — sharing an
// existing calendar is done via ShareCalendar, not by creating one
// directly in someone else's name.
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 {
requester, localName, err := b.parseCalPath(ctx, calPath)
if err != nil {
return err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
}
return b.store.DeleteCollection(owner, "cal-"+realName)
}
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return nil, err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
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(owner, "cal-"+realName, 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 {
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
if err != nil {
return err
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
}
if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
return nil
}
// -------- helpers --------
// sharedCalendarName builds the synthetic local name a shared calendar is
// exposed under to the user it was shared with.
func sharedCalendarName(owner, calName string) string {
return owner + sharedNameSep + calName
}
// resolveCalendar maps a local calendar name (as seen in a URL path by
// requester) to its real owner and on-disk name, checking permissions
// along the way. If localName contains sharedNameSep, it's treated as a
// reference to another user's calendar and looked up in the shares table;
// otherwise it's assumed to be one of requester's own calendars.
//
// If requireWrite is true, a share must grant PermWrite or this returns a
// 403 Forbidden error. Owners always have full access to their own
// calendars.
func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool) (owner, realName string, perm db.Permission, err error) {
if ownerName, calName, ok := strings.Cut(localName, sharedNameSep); ok {
if b.dbase == nil {
return "", "", "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("sharing not enabled"))
}
share, err := b.dbase.CalendarShareFor(ownerName, calName, requester)
if err != nil {
return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("calendar not shared with you"))
}
if requireWrite && share.Permission != db.PermWrite {
return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("read-only share"))
}
return ownerName, calName, share.Permission, nil
}
return requester, localName, db.PermWrite, nil
}
func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar {
desc := fmt.Sprintf("%s's %s calendar", owner, realName)
return caldav.Calendar{
Path: calHomePath() + localName + "/",
Name: localName,
Description: desc,
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/home/<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/home/<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
}
// calPrincipalPath, calHomePath, and calObjectPath are the same for every
// user: authorization is resolved from the Basic Auth identity, not from
// the URL, so no username segment is needed in the path.
//
// The fixed "home" segment (in place of a username) is required, not
// cosmetic: go-webdav's caldav server classifies a request purely by how
// many path segments it has relative to the handler's mount point — 1
// segment is treated as the principal, 2 as the calendar-home-set, 3 as a
// calendar, 4 as a calendar object. Removing that segment entirely would
// make the home-set and calendar paths misclassified as principal/home-set
// respectively, breaking discovery (empty PROPFIND responses).
func calPrincipalPath() string {
return "/cal/"
}
func calHomePath() string {
return "/cal/home/"
}
func calObjectPath(calName, objID string) string {
return fmt.Sprintf("/cal/home/%s/%s", calName, objID)
}
func hashBytes(data []byte) uint64 {
var h uint64 = 14695981039346656037
for _, b := range data {
h ^= uint64(b)
h *= 1099511628211
}
return h
}