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
+101 -7
View File
@@ -83,9 +83,10 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
// store.Store/db.DB. If requireWrite is true, a read-only share is
// rejected with errCalendarReadOnly.
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
if ref == birthdaysCalRef {
// The birthdays calendar is virtual/computed, not backed by any
// stored calendar object — there's nothing to resolve to.
if ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) {
// Both the birthdays calendar and ICS subscriptions are
// virtual/computed, not backed by any stored calendar object —
// there's nothing to resolve to.
return "", "", errCalendarNotFound
}
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
@@ -123,7 +124,8 @@ type calendarEntry struct {
Name string // calendar's own name (unqualified)
Color string
Writable bool
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from
}
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
@@ -133,6 +135,19 @@ type calendarEntry struct {
// mistaken for an "owner~name" shared-calendar ref either).
const birthdaysCalRef = "@birthdays"
// icsRefPrefix marks a calendarEntry's Ref as referring to one of
// username's own ICS/webcal subscriptions (see internal/db/ics.go). Like
// "@" for the birthdays calendar, "!" isn't in resourceNameRe's character
// class and can't appear in a calRefSep-joined shared-calendar ref either,
// so "!<name>" can't collide with any other kind of ref.
const icsRefPrefix = "!"
// icsCalRef builds the reference string for one of username's own ICS
// subscriptions named name.
func icsCalRef(name string) string {
return icsRefPrefix + name
}
// defaultBirthdayColor is the display color for the virtual birthdays
// calendar used until the user picks their own from the dashboard (a
// pink, distinct from typical user-picked calendar colors).
@@ -150,8 +165,8 @@ func (s *Server) birthdayCalendarColor(username string) string {
// listCalendarEntries returns every calendar visible to username: the
// synthetic birthdays calendar, their own calendars (always writable),
// and any calendars shared with them (writable only if the share grants
// write permission).
// any calendars shared with them (writable only if the share grants write
// permission), and their own read-only ICS/webcal subscriptions.
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
entries := []calendarEntry{
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
@@ -185,6 +200,17 @@ func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
})
}
subs, err := s.dbase.ListICSSubscriptions(username)
if err != nil {
return nil, err
}
for _, sub := range subs {
entries = append(entries, calendarEntry{
Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color,
Writable: false, Virtual: true, ICSURL: sub.URL,
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Owner != entries[j].Owner {
return entries[i].Owner < entries[j].Owner
@@ -283,6 +309,13 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
continue
}
if strings.HasPrefix(entry.Ref, icsRefPrefix) {
if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil {
s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err)
}
continue
}
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
if err != nil {
continue
@@ -766,8 +799,14 @@ func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
if len(events) == 0 {
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
}
ev := events[0]
return eventFormFromComponent(id, events[0])
}
// eventFormFromComponent extracts an EventFormData from a single decoded
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
// and the ICS-subscription rendering path (many events per fetched
// calendar, see addICSEvents).
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
form := templates.EventFormData{ID: id}
if p := ev.Props.Get(ical.PropSummary); p != nil {
form.Summary = p.Value
@@ -1031,3 +1070,58 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
}
return nil
}
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
// which caches it for a while so every month-view render doesn't re-fetch
// from origin) and places each VEVENT's occurrence onto the month grid,
// the same way a stored calendar object would be. There's no per-event
// edit page for these (the source is external and read-only), so each
// event's LinkURL is left pointing nowhere useful ("#").
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
cal, err := s.icsCache.Get(entry.ICSURL)
if err != nil {
return err
}
const totalDays = 42
for i, ev := range cal.Events() {
id := fmt.Sprintf("ics-%d", i)
form, err := eventFormFromComponent(id, ev)
if err != nil {
continue
}
startDay, endDay, err := eventDayRange(form, loc)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
}
if startDay.Before(gridStart) {
startDay = gridStart
}
if endDay.After(gridEnd) {
endDay = gridEnd
}
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
CalRef: entry.Ref,
Color: entry.Color,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
LinkURL: "#",
})
}
}
return nil
}