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:
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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/db"
|
||||
)
|
||||
|
||||
// defaultICSColor is the display color for an ICS/webcal subscription
|
||||
// calendar when the user hasn't chosen one, matching internal/web's.
|
||||
const defaultICSColor = "#0ea5e9"
|
||||
|
||||
// icsSubscriptionCalendarMeta returns the caldav.Calendar metadata for
|
||||
// owner's ICS subscription sub, exposed under its own chosen name.
|
||||
func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscription) caldav.Calendar {
|
||||
return caldav.Calendar{
|
||||
Path: calHomePath() + sub.Name + "/",
|
||||
Name: sub.Name,
|
||||
Description: "Read-only subscription: " + sub.URL,
|
||||
SupportedComponentSet: []string{"VEVENT"},
|
||||
MaxResourceSize: 256 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
// icsObjectUID returns the UID a fetched VEVENT should be addressed by:
|
||||
// its own UID property if it has one, otherwise a stable hash of its
|
||||
// position so it still round-trips consistently between requests.
|
||||
func icsObjectUID(ev ical.Event, fallback string) string {
|
||||
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
|
||||
return p.Value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to
|
||||
// address a fetched VEVENT within its subscription calendar, derived from
|
||||
// its UID so it stays stable across fetches of the same feed.
|
||||
func icsObjID(uid string) string {
|
||||
sum := sha1.Sum([]byte(uid))
|
||||
return hex.EncodeToString(sum[:]) + ".ics"
|
||||
}
|
||||
|
||||
// listICSSubscriptionCalendarObjects fetches sub's remote calendar (via
|
||||
// b.icsCache) and returns one caldav.CalendarObject per VEVENT.
|
||||
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
|
||||
cal, err := b.icsCache.Get(sub.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching ics subscription %q: %w", sub.Name, err)
|
||||
}
|
||||
|
||||
var objs []caldav.CalendarObject
|
||||
for i, ev := range cal.Events() {
|
||||
obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
objs = append(objs, *obj)
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
// icsSubscriptionCalendarObject fetches sub's remote calendar and returns
|
||||
// the single VEVENT whose derived object ID matches objID.
|
||||
func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.ICSSubscription) (*caldav.CalendarObject, error) {
|
||||
cal, err := b.icsCache.Get(sub.URL)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
|
||||
}
|
||||
for i, ev := range cal.Events() {
|
||||
uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i))
|
||||
if icsObjID(uid) != objID {
|
||||
continue
|
||||
}
|
||||
return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
|
||||
}
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
|
||||
}
|
||||
|
||||
// encodeICSObject wraps a single fetched VEVENT ev into its own
|
||||
// caldav.CalendarObject, encoding it as a standalone one-event calendar
|
||||
// the same way every other calendar object in this backend is
|
||||
// represented. fallbackUID is used to derive the object ID/UID if ev has
|
||||
// no UID property of its own.
|
||||
func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) {
|
||||
uid := icsObjectUID(ev, fallbackUID)
|
||||
objID := icsObjID(uid)
|
||||
|
||||
event := ical.NewEvent()
|
||||
event.Props = ev.Props
|
||||
if event.Props.Get(ical.PropUID) == nil {
|
||||
event.Props.SetText(ical.PropUID, uid)
|
||||
}
|
||||
if event.Props.Get(ical.PropDateTimeStamp) == nil {
|
||||
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||
}
|
||||
|
||||
out := ical.NewCalendar()
|
||||
out.Props.SetText(ical.PropVersion, "2.0")
|
||||
out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN")
|
||||
out.Children = append(out.Children, event.Component)
|
||||
|
||||
var buf strings.Builder
|
||||
if err := ical.NewEncoder(&buf).Encode(out); err != nil {
|
||||
return nil, fmt.Errorf("encoding ics subscription event: %w", err)
|
||||
}
|
||||
data := []byte(buf.String())
|
||||
|
||||
return &caldav.CalendarObject{
|
||||
Path: calObjectPath(localName, objID),
|
||||
ModTime: time.Now(),
|
||||
ContentLength: int64(len(data)),
|
||||
ETag: fmt.Sprintf(`"ics-%s"`, objID),
|
||||
Data: out,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user