Add ICS/webcal HTTP subscription calendars

Users can now subscribe to a remote ICS/webcal feed from the dashboard
(name + color), the same way they set up a real calendar or the virtual
Birthdays calendar. Subscriptions are read-only, per-user, and share the
"/cal/home/<name>/" namespace with real calendars and "birthdays" (name
collisions are rejected in both directions).

- internal/db: new ics_subscriptions table + CRUD (internal/db/ics.go);
  CreateCalendarWithColor checks for a colliding subscription name.
- internal/icssub: shared HTTP-fetch + TTL cache (15 min) for remote ICS
  calendars, with webcal:// -> https:// rewriting and stale-on-error
  fallback, used by both the web UI and the CalDAV backend.
- internal/web: dashboard "Subscribe to an ICS/webcal calendar" form,
  color picker, delete button (internal/web/ics.go,
  templates/dashboard.templ); month view renders subscription events in
  their chosen color, read-only (internal/web/calendar.go).
- internal/caldav: subscriptions are exposed as read-only calendars
  (internal/caldav/ics.go) - listed in PROPFIND, events served via
  GET/REPORT, PUT/DELETE on individual events rejected with 403, but
  DELETE on the calendar itself unsubscribes; calendar-color is injected
  the same way as for real calendars and Birthdays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 22:10:49 +02:00
co-authored by Copilot
parent 2cb6e98db6
commit 1707ab4060
12 changed files with 818 additions and 104 deletions
+44 -5
View File
@@ -20,6 +20,7 @@ import (
"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/icssub"
"github.com/yourusername/caldav-server/internal/store"
)
@@ -32,16 +33,17 @@ 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
cfg *config.Config
store *store.Store
dbase *db.DB // may be nil if sharing is not configured
logger *slog.Logger
icsCache *icssub.Cache
}
// 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}
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
}
// NewHandler returns an http.Handler for the /cal/ prefix.
@@ -100,6 +102,15 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
}
// Also include the requester's own read-only ICS/webcal subscriptions.
subs, err := b.dbase.ListICSSubscriptions(p.Username)
if err != nil {
b.logger.Warn("listing ics subscriptions", "error", err)
}
for _, sub := range subs {
cals = append(cals, b.icsSubscriptionCalendarMeta(p.Username, sub))
}
// Also include any extra calendars that exist on disk but aren't registered
disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool)
@@ -140,6 +151,10 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale
cal := b.birthdaysCalendarMeta(requester)
return &cal, nil
}
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
cal := b.icsSubscriptionCalendarMeta(requester, sub)
return &cal, nil
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -159,6 +174,9 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
if localName == birthdaysCalendarName {
return b.birthdayCalendarObject(requester, objPath, objID)
}
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
return b.icsSubscriptionCalendarObject(localName, objID, sub)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -180,6 +198,9 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
if localName == birthdaysCalendarName {
return b.listBirthdayCalendarObjects(requester)
}
if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
return b.listICSSubscriptionCalendarObjects(localName, sub)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -244,6 +265,13 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
// Unlike the birthdays calendar, an ICS subscription can be deleted:
// that's how a user unsubscribes from it via CalDAV.
if err := b.dbase.DeleteICSSubscription(requester, localName); err == nil {
return nil
} else if err != db.ErrResourceNotFound {
return fmt.Errorf("unregistering ics subscription: %w", err)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
@@ -262,6 +290,9 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
if localName == birthdaysCalendarName {
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return nil, err
@@ -289,6 +320,9 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
@@ -531,6 +565,11 @@ func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body [
if color == "" {
color = defaultBirthdayColor
}
} else if sub, serr := h.backend.dbase.GetICSSubscription(p.Username, localName); serr == nil {
color = sub.Color
if color == "" {
color = defaultICSColor
}
} else {
owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false)
if rerr != nil {