Files
nidus/internal/caldav/ics.go
T
arnefandCopilot 1707ab4060 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>
2026-08-20 22:10:49 +02:00

125 lines
4.3 KiB
Go

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
}