From 1707ab40604dcfdb6270f8e5b93650e705dc6498 Mon Sep 17 00:00:00 2001 From: arnef Date: Thu, 20 Aug 2026 22:10:49 +0200 Subject: [PATCH] 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//" 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> --- internal/caldav/backend.go | 49 +++++- internal/caldav/ics.go | 124 ++++++++++++++ internal/db/db.go | 13 ++ internal/db/ics.go | 98 +++++++++++ internal/db/users.go | 10 ++ internal/icssub/icssub.go | 120 +++++++++++++ internal/web/calendar.go | 108 +++++++++++- internal/web/dashboard.go | 8 + internal/web/ics.go | 130 +++++++++++++++ internal/web/server.go | 14 +- internal/web/templates/dashboard.templ | 53 +++++- internal/web/templates/dashboard_templ.go | 195 +++++++++++++--------- 12 files changed, 818 insertions(+), 104 deletions(-) create mode 100644 internal/caldav/ics.go create mode 100644 internal/db/ics.go create mode 100644 internal/icssub/icssub.go create mode 100644 internal/web/ics.go diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index e0a1cbb..e2f4120 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -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 { diff --git a/internal/caldav/ics.go b/internal/caldav/ics.go new file mode 100644 index 0000000..f064a5c --- /dev/null +++ b/internal/caldav/ics.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go index cf05862..bd509d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -105,6 +105,19 @@ CREATE TABLE IF NOT EXISTS birthday_calendars ( color TEXT NOT NULL DEFAULT '' ); +-- User-added read-only calendars backed by a remote ICS/webcal URL (see +-- internal/db/ics.go, internal/web/ics.go, internal/caldav/ics.go). The +-- local calendar name (like real calendars) is unique per owner and lives +-- in the same "/cal/home//" namespace, so name collisions with the +-- calendars table and reservedCalendarNames are checked at creation time. +CREATE TABLE IF NOT EXISTS ics_subscriptions ( + owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '', + PRIMARY KEY (owner, name) +); + -- Web UI login sessions. Sessions are opaque random tokens stored server -- side (not JWTs) so they can be revoked instantly by deleting the row. CREATE TABLE IF NOT EXISTS web_sessions ( diff --git a/internal/db/ics.go b/internal/db/ics.go new file mode 100644 index 0000000..a4b6bb6 --- /dev/null +++ b/internal/db/ics.go @@ -0,0 +1,98 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// ICSSubscription is a user-added read-only calendar backed by a remote +// ICS/webcal URL, exposed alongside real calendars under +// "/cal/home//" (see internal/caldav/ics.go). +type ICSSubscription struct { + Name string + URL string + Color string +} + +// CreateICSSubscription registers a new ICS subscription owned by owner. +// Returns ErrReservedName if name is reserved for a computed calendar (see +// reservedCalendarNames), or ErrResourceExists if name is already used by +// one of owner's calendars or ICS subscriptions. +func (d *DB) CreateICSSubscription(owner, name, url, color string) error { + if reservedCalendarNames[strings.ToLower(name)] { + return ErrReservedName + } + // Shares the "/cal/home//" namespace with real calendars, so + // reject a name already taken by one of those. + var calExists int + if err := d.conn.QueryRow(`SELECT COUNT(*) FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&calExists); err != nil { + return fmt.Errorf("checking calendar name: %w", err) + } + if calExists > 0 { + return ErrResourceExists + } + _, err := d.conn.Exec(`INSERT INTO ics_subscriptions (owner, name, url, color) VALUES (?, ?, ?, ?)`, owner, name, url, color) + if err != nil { + if isUniqueConstraintErr(err) { + return ErrResourceExists + } + return fmt.Errorf("creating ics subscription: %w", err) + } + return nil +} + +// DeleteICSSubscription removes an ICS subscription. Returns +// ErrResourceNotFound if it doesn't exist. +func (d *DB) DeleteICSSubscription(owner, name string) error { + res, err := d.conn.Exec(`DELETE FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name) + if err != nil { + return fmt.Errorf("deleting ics subscription: %w", err) + } + return requireRowsAffected(res, ErrResourceNotFound) +} + +// SetICSSubscriptionColor updates an ICS subscription's display color. +// Returns ErrResourceNotFound if it doesn't exist. +func (d *DB) SetICSSubscriptionColor(owner, name, color string) error { + res, err := d.conn.Exec(`UPDATE ics_subscriptions SET color = ? WHERE owner = ? AND name = ?`, color, owner, name) + if err != nil { + return fmt.Errorf("setting ics subscription color: %w", err) + } + return requireRowsAffected(res, ErrResourceNotFound) +} + +// GetICSSubscription returns owner's ICS subscription registered under +// name. Returns ErrResourceNotFound if it doesn't exist. +func (d *DB) GetICSSubscription(owner, name string) (ICSSubscription, error) { + sub := ICSSubscription{Name: name} + err := d.conn.QueryRow(`SELECT url, color FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&sub.URL, &sub.Color) + if errors.Is(err, sql.ErrNoRows) { + return ICSSubscription{}, ErrResourceNotFound + } + if err != nil { + return ICSSubscription{}, fmt.Errorf("getting ics subscription: %w", err) + } + return sub, nil +} + +// ListICSSubscriptions returns all of owner's ICS subscriptions, sorted by +// name. +func (d *DB) ListICSSubscriptions(owner string) ([]ICSSubscription, error) { + rows, err := d.conn.Query(`SELECT name, url, color FROM ics_subscriptions WHERE owner = ? ORDER BY name`, owner) + if err != nil { + return nil, fmt.Errorf("listing ics subscriptions: %w", err) + } + defer rows.Close() + + var subs []ICSSubscription + for rows.Next() { + var s ICSSubscription + if err := rows.Scan(&s.Name, &s.URL, &s.Color); err != nil { + return nil, fmt.Errorf("scanning ics subscription: %w", err) + } + subs = append(subs, s) + } + return subs, rows.Err() +} diff --git a/internal/db/users.go b/internal/db/users.go index 9e4537f..0db97cb 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -193,6 +193,16 @@ func (d *DB) CreateCalendarWithColor(owner, name, color string) error { if reservedCalendarNames[strings.ToLower(name)] { return ErrReservedName } + // The "/cal/home//" namespace is shared with ICS subscriptions + // (see internal/db/ics.go), so a real calendar can't be created under + // a name already taken by one of those either. + var subExists int + if err := d.conn.QueryRow(`SELECT COUNT(*) FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&subExists); err != nil { + return fmt.Errorf("checking ics subscription name: %w", err) + } + if subExists > 0 { + return ErrResourceExists + } _, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color) if err != nil { if isUniqueConstraintErr(err) { diff --git a/internal/icssub/icssub.go b/internal/icssub/icssub.go new file mode 100644 index 0000000..ae91293 --- /dev/null +++ b/internal/icssub/icssub.go @@ -0,0 +1,120 @@ +// Package icssub fetches and caches remote ICS/webcal calendars, shared by +// the web calendar UI (internal/web) and the CalDAV backend +// (internal/caldav) so both render identical events for a user's ICS +// subscriptions without duplicating fetch/parse/cache logic. +package icssub + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + ical "github.com/emersion/go-ical" +) + +// DefaultTTL is how long a fetched calendar is cached before being +// re-fetched on the next access. +const DefaultTTL = 15 * time.Minute + +// fetchTimeout bounds how long a single upstream request may take, so one +// slow/unreachable subscription can't stall a page render indefinitely. +const fetchTimeout = 15 * time.Second + +// maxBodySize caps how much of a remote calendar is read, guarding +// against a malicious or misconfigured URL streaming an unbounded +// response. +const maxBodySize = 32 * 1024 * 1024 // 32 MiB + +type entry struct { + fetchedAt time.Time + cal *ical.Calendar + err error +} + +// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived +// in-memory copy per URL so repeated renders (e.g. every month-view page +// load, or CalDAV client polling) don't re-fetch the same subscription +// from origin every time. +type Cache struct { + ttl time.Duration + client *http.Client + + mu sync.Mutex + entries map[string]entry +} + +// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure). +func NewCache(ttl time.Duration) *Cache { + return &Cache{ + ttl: ttl, + client: &http.Client{Timeout: fetchTimeout}, + entries: make(map[string]entry), + } +} + +// Get returns the parsed calendar fetched from url, using a cached copy +// if it's still within the TTL. If a fresh fetch fails but a previously +// fetched copy exists, the stale copy is returned instead of the error, +// so a transient network issue doesn't blank out the calendar entirely. +func (c *Cache) Get(url string) (*ical.Calendar, error) { + c.mu.Lock() + e, ok := c.entries[url] + fresh := ok && time.Since(e.fetchedAt) < c.ttl + c.mu.Unlock() + if fresh { + return e.cal, e.err + } + + cal, err := c.fetch(url) + c.mu.Lock() + defer c.mu.Unlock() + if err != nil && ok && e.cal != nil { + return e.cal, nil + } + c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err} + return cal, err +} + +// fetch downloads and parses url, translating a "webcal://" scheme (used +// by some calendar-subscription links) to "https://" first, since Go's +// http.Client has no built-in handler for it. +func (c *Cache) fetch(rawURL string) (*ical.Calendar, error) { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizeURL(rawURL), nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Accept", "text/calendar, */*") + req.Header.Set("User-Agent", "nidus-ics-subscription/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching calendar: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status) + } + + cal, err := ical.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode() + if err != nil { + return nil, fmt.Errorf("parsing calendar: %w", err) + } + return cal, nil +} + +// normalizeURL rewrites a "webcal://" URL to "https://" so it can be +// fetched with a normal HTTP client. +func normalizeURL(u string) string { + if rest, ok := strings.CutPrefix(u, "webcal://"); ok { + return "https://" + rest + } + return u +} diff --git a/internal/web/calendar.go b/internal/web/calendar.go index bfadf15..4b387f3 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -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 "!" 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 +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index a00bb22..9a870c5 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -93,6 +93,14 @@ func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error resources = append(resources, card) } + subs, err := s.dbase.ListICSSubscriptions(username) + if err != nil { + return nil, fmt.Errorf("listing ics subscriptions: %w", err) + } + for _, sub := range subs { + resources = append(resources, templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL}) + } + return resources, nil } diff --git a/internal/web/ics.go b/internal/web/ics.go new file mode 100644 index 0000000..427790f --- /dev/null +++ b/internal/web/ics.go @@ -0,0 +1,130 @@ +package web + +import ( + "context" + "net/http" + "strings" + + "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/web/templates" +) + +// icsURLValid does a light sanity check on a subscription URL: it must be +// http(s):// (fetched directly) or webcal:// (rewritten to https:// by +// internal/icssub before fetching), and within a reasonable length. +func icsURLValid(u string) bool { + if len(u) == 0 || len(u) > 2048 { + return false + } + return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "webcal://") +} + +// handleICSResource handles POST (create) and DELETE (remove) for the +// current user's ICS/webcal calendar subscriptions, mounted at +// /resources/ics. +func (s *Server) handleICSResource(w http.ResponseWriter, r *http.Request) { + username := userFromContext(r.Context()) + + // htmx v2 sends DELETE request parameters as URL query parameters, not + // a request body (see internal/web/resources.go's handleResource). + if r.Method == http.MethodDelete { + r.PostForm = r.URL.Query() + } else if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.PostForm.Get("name")) + if !resourceNameRe.MatchString(name) { + http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPost: + url := strings.TrimSpace(r.PostForm.Get("url")) + if !icsURLValid(url) { + http.Error(w, "url must be a http://, https:// or webcal:// address", http.StatusBadRequest) + return + } + color := strings.TrimSpace(r.PostForm.Get("color")) + if color != "" && !hexColorRe.MatchString(color) { + http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) + return + } + if err := s.dbase.CreateICSSubscription(username, name, url, color); err != nil { + if err == db.ErrResourceExists { + http.Error(w, "already exists", http.StatusConflict) + return + } + if err == db.ErrReservedName { + http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict) + return + } + s.logger.Error("creating ics subscription", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + case http.MethodDelete: + if err := s.dbase.DeleteICSSubscription(username, name); err != nil && err != db.ErrResourceNotFound { + s.logger.Error("deleting ics subscription", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + default: + w.Header().Set("Allow", "POST, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // The set of cards changed (one added/removed), so re-render the + // whole #resources list rather than a single card. + resources, err := s.resourceCards(username) + if err != nil { + s.logger.Error("listing resources", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.ResourceList(resources).Render(context.Background(), w) +} + +// handleICSColor updates the color of one of the current user's ICS +// subscriptions, mounted at /resources/ics/color. It re-renders just that +// card (not the whole list), since the set of cards doesn't change. +func (s *Server) handleICSColor(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username := userFromContext(r.Context()) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.PostForm.Get("name")) + color := strings.TrimSpace(r.PostForm.Get("color")) + if !resourceNameRe.MatchString(name) { + http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) + return + } + if color != "" && !hexColorRe.MatchString(color) { + http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) + return + } + if err := s.dbase.SetICSSubscriptionColor(username, name, color); err != nil && err != db.ErrResourceNotFound { + s.logger.Error("setting ics subscription color", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + sub, err := s.dbase.GetICSSubscription(username, name) + if err != nil { + s.logger.Error("loading ics subscription", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + card := templates.ResourceCard{Kind: "ics", Name: sub.Name, Color: sub.Color, URL: sub.URL} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.ResourceCardView(card).Render(r.Context(), w) +} diff --git a/internal/web/server.go b/internal/web/server.go index 9eaf37e..6ebe29f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -12,20 +12,22 @@ import ( "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" ) // Server holds the dependencies needed by the web UI handlers. type Server struct { - cfg *config.Config - store *store.Store - dbase *db.DB - logger *slog.Logger + cfg *config.Config + store *store.Store + dbase *db.DB + logger *slog.Logger + icsCache *icssub.Cache } // NewServer constructs a web UI Server. func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server { - return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger} + return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} } // Handler returns the http.Handler serving the web UI, mounted at "/web/" @@ -46,6 +48,8 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor)) mux.HandleFunc("/resources/birthdays/color", s.requireLogin(s.handleBirthdayColor)) mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) + mux.HandleFunc("/resources/ics", s.requireLogin(s.handleICSResource)) + mux.HandleFunc("/resources/ics/color", s.requireLogin(s.handleICSColor)) mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles)) mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome)) mux.HandleFunc("/contacts/{book}", s.requireLogin(s.handleContactsList)) diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index bf383a7..29ee6a9 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -11,9 +11,10 @@ type ShareRow struct { // ResourceCard describes one of the user's own calendars/address books // plus who it's currently shared with. type ResourceCard struct { - Kind string // "calendar" or "addressbook" + Kind string // "calendar", "addressbook", or "ics" (read-only ICS subscription) Name string - Color string // hex color like "#3b82f6"; only used for calendars + Color string // hex color like "#3b82f6"; only used for calendars/ics + URL string // remote ICS/webcal URL; only used for kind "ics" Shares []ShareRow Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker } @@ -73,6 +74,32 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared Add +
+
+ + + +
+
+ + +
+ +
@ResourceList(resources) @@ -114,7 +141,7 @@ templ ResourceCardView(r ResourceCard) {

- if r.Kind == "calendar" { + if r.Kind == "calendar" || r.Kind == "ics" {
(computed from contacts) } + if r.Kind == "ics" { + (read-only subscription) + }

if !r.Virtual {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Your calendars & address books

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,7 +94,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -106,7 +107,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -119,7 +120,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 87, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -132,7 +133,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 116, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -234,7 +235,7 @@ func ResourceCardView(r ResourceCard) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 141, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -244,7 +245,7 @@ func ResourceCardView(r ResourceCard) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if r.Kind == "calendar" { + if r.Kind == "calendar" || r.Kind == "ics" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
(computed from contacts)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "(computed from contacts) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if r.Kind == "ics" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "(read-only subscription)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !r.Virtual { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if !r.Virtual { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
    ") + if r.Kind == "ics" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.URL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 185, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if !r.Virtual && r.Kind != "ics" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, sh := range r.Shares { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
    • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission) + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 192, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" hx-swap=\"outerHTML\" hx-confirm=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 201, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Remove") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(r.Shares) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    • Not shared with anyone yet.
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
    • Not shared with anyone yet.
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-swap=\"outerHTML\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -538,6 +564,9 @@ func colorEndpoint(r ResourceCard) string { if r.Virtual { return "/web/resources/birthdays/color" } + if r.Kind == "ics" { + return "/web/resources/ics/color" + } return "/web/resources/calendar/color" } @@ -553,10 +582,14 @@ func shareVals(resource, sharedWith string) string { } func resourceEndpoint(kind string) string { - if kind == "calendar" { + switch kind { + case "calendar": return "/web/resources/calendar" + case "ics": + return "/web/resources/ics" + default: + return "/web/resources/addressbook" } - return "/web/resources/addressbook" } func resourceVals(name string) string {