diff --git a/cmd/server/main.go b/cmd/server/main.go
index 935e43b..c027c5d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -17,6 +17,7 @@ import (
"git.arnef.de/arnef/nidus/internal/carddav"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
+ "git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web"
filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
@@ -103,10 +104,16 @@ func main() {
authMw := auth.NewMiddleware(cfg, dbase, logger)
// ---- Handlers ----
- calHandler := caldav.NewHandler(cfg, st, dbase, logger)
+ // A single ICS-subscription cache is shared by both the CalDAV backend
+ // (for DAV clients) and the web UI (for the browser calendar page), so
+ // the same subscription is fetched and served identically regardless
+ // of which surface a client hits, and one background refresh refreshes
+ // both at once.
+ icsCache := icssub.NewCache(icssub.DefaultTTL)
+ calHandler := caldav.NewHandler(cfg, st, dbase, logger, icsCache)
cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger)
- webUI := web.NewServer(cfg, st, dbase, logger)
+ webUI := web.NewServer(cfg, st, dbase, logger, icsCache)
mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger)
diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go
index cee8445..2f78184 100644
--- a/internal/caldav/backend.go
+++ b/internal/caldav/backend.go
@@ -41,10 +41,16 @@ type Backend struct {
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, icsCache: icssub.NewCache(icssub.DefaultTTL)}
+// NewBackend creates a CalDAV backend over an existing ICS cache.
+// dbase may be nil, in which case calendar sharing is disabled (only a
+// user's own calendars are visible). The icsCache is shared with any
+// other consumers (notably the web UI) so CalDAV and the web calendar
+// page see identical, cache-consistent events for ICS subscriptions.
+func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Backend {
+ if icsCache == nil {
+ icsCache = icssub.NewCache(icssub.DefaultTTL)
+ }
+ return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache}
}
// NewHandler returns an http.Handler for the /cal/ prefix.
@@ -54,8 +60,8 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.
// property into PROPFIND responses for calendar collections, since
// go-webdav's caldav.Backend interface has no extension point for
// vendor-specific WebDAV properties.
-func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
- b := NewBackend(cfg, st, dbase, logger)
+func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) http.Handler {
+ b := NewBackend(cfg, st, dbase, logger, icsCache)
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
}
diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go
index f51cf46..a46af33 100644
--- a/internal/caldav/backend_test.go
+++ b/internal/caldav/backend_test.go
@@ -46,7 +46,7 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
t.Fatalf("CreateCalendar bob/personal: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- return NewBackend(cfg, st, dbase, logger), dbase
+ return NewBackend(cfg, st, dbase, logger, nil), dbase
}
func ctxFor(username string) context.Context {
@@ -179,7 +179,7 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- handler := NewHandler(&config.Config{}, st, dbase, logger)
+ handler := NewHandler(&config.Config{}, st, dbase, logger, nil)
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
``))
@@ -250,7 +250,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- handler := NewHandler(&config.Config{}, st, dbase, logger)
+ handler := NewHandler(&config.Config{}, st, dbase, logger, nil)
req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader(
``+
diff --git a/internal/caldav/ics.go b/internal/caldav/ics.go
index 541a915..daa5293 100644
--- a/internal/caldav/ics.go
+++ b/internal/caldav/ics.go
@@ -1,8 +1,6 @@
package caldav
import (
- "crypto/sha1"
- "encoding/hex"
"fmt"
"net/http"
"strings"
@@ -13,6 +11,7 @@ import (
"github.com/emersion/go-webdav/caldav"
"git.arnef.de/arnef/nidus/internal/db"
+ "git.arnef.de/arnef/nidus/internal/icssub"
)
// defaultICSColor is the display color for an ICS/webcal subscription
@@ -32,8 +31,9 @@ func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscripti
}
// 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.
+// its own UID property if it has one, otherwise a placeholder derived from
+// the object ID (so the event still has a *unique* UID in the returned
+// VCALENDAR).
func icsObjectUID(ev ical.Event, fallback string) string {
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
return p.Value
@@ -41,16 +41,11 @@ func icsObjectUID(ev ical.Event, fallback string) string {
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.
+// icsSubscriptionCalendarObjects fetches sub's remote calendar (via
+// b.icsCache) and returns one caldav.CalendarObject per VEVENT. The
+// object path is derived from icssub.EventID(ev) so the same event keeps
+// the same path across fetches, even if its position in the document
+// changes.
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
cal, err := b.icsCache.Get(sub.URL)
if err != nil {
@@ -58,8 +53,8 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
}
var objs []caldav.CalendarObject
- for i, ev := range cal.Events() {
- obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
+ for _, ev := range cal.Events() {
+ obj, err := b.encodeICSObject(localName, ev)
if err != nil {
continue
}
@@ -75,24 +70,25 @@ func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.
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 {
+ for _, ev := range cal.Events() {
+ if icssub.EventID(ev) != objID {
continue
}
- return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
+ return b.encodeICSObject(localName, ev)
}
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)
+// the same way every other calendar object in this backend is represented.
+func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) {
+ objID := icssub.EventID(ev)
+ if objID == "" {
+ // Not addressable (no DTSTART) — skip.
+ return nil, fmt.Errorf("event has no DTSTART; not addressable")
+ }
+ uid := icsObjectUID(ev, objID)
event := ical.NewEvent()
event.Props = ev.Props
diff --git a/internal/caldav/ics_shared_test.go b/internal/caldav/ics_shared_test.go
new file mode 100644
index 0000000..4f26aa6
--- /dev/null
+++ b/internal/caldav/ics_shared_test.go
@@ -0,0 +1,268 @@
+package caldav
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "git.arnef.de/arnef/nidus/internal/auth"
+ "git.arnef.de/arnef/nidus/internal/config"
+ "git.arnef.de/arnef/nidus/internal/db"
+ "git.arnef.de/arnef/nidus/internal/icssub"
+ "git.arnef.de/arnef/nidus/internal/store"
+ ical "github.com/emersion/go-ical"
+)
+
+// icsSample is a minimal valid ICS feed with one VEVENT.
+const icsSample = "BEGIN:VCALENDAR\r\n" +
+ "VERSION:2.0\r\n" +
+ "PRODID:-//nidus//test//EN\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:shared1@nidus.test\r\n" +
+ "DTSTAMP:20260101T000000Z\r\n" +
+ "DTSTART:20260805T090000Z\r\n" +
+ "DTEND:20260805T100000Z\r\n" +
+ "SUMMARY:Shared event\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+
+// mustCalEvent parses raw and returns its first VEVENT (panic on error;
+// safe in tests).
+func mustCalEvent(t *testing.T, raw string) ical.Event {
+ t.Helper()
+ _ = t
+ cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
+ if err != nil {
+ panic("mustCalEvent: " + err.Error())
+ }
+ evs := cal.Events()
+ if len(evs) == 0 {
+ panic("mustCalEvent: no events")
+ }
+ return evs[0]
+}
+
+// TestICSSubscriptionListAndGetObject verifies the full CalDAV read path
+// for an ICS subscription: ListCalendarObjects returns one synthetic
+// stand-alone object whose Path is derived from icssub.EventID (not from
+// the event's position), and the same object is returned via
+// GetCalendarObject by the same Path.
+func TestICSSubscriptionListAndGetObject(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsSample))
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ st, err := store.NewStore(filepath.Join(dir, "data"))
+ if err != nil {
+ t.Fatalf("NewStore: %v", err)
+ }
+ dbase, err := db.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ defer dbase.Close()
+
+ if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
+ t.Fatalf("CreateICSSubscription: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
+
+ ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
+
+ objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
+ if err != nil {
+ t.Fatalf("ListCalendarObjects: %v", err)
+ }
+ if len(objs) != 1 {
+ t.Fatalf("expected 1 object, got %d", len(objs))
+ }
+
+ // The Path must be derived from the event's DTSTART/DTEND/SUMMARY —
+ // i.e. icssub.EventID(ev), not from the event's position in the feed.
+ ev := mustCalEvent(t, icsSample)
+ wantID := icssub.EventID(ev)
+ if wantID == "" {
+ t.Fatal("EventID must be non-empty for a valid event")
+ }
+ wantPath := calObjectPath("holidays", wantID)
+ if objs[0].Path != wantPath {
+ t.Fatalf("Path = %q, want %q (icssub.EventID-based, not position-based)", objs[0].Path, wantPath)
+ }
+
+ // GetCalendarObject by the same Path should return an object that has
+ // a valid VEVENT whose UID matches our source ICS (round-trip
+ // correctness).
+ found, err := b.GetCalendarObject(ctx, objs[0].Path, nil)
+ if err != nil {
+ t.Fatalf("GetCalendarObject: %v", err)
+ }
+ if found.Path != objs[0].Path {
+ t.Fatalf("round-trip path mismatch: %q vs %q", found.Path, objs[0].Path)
+ }
+ if found.Data == nil || len(found.Data.Events()) == 0 {
+ t.Fatalf("expected found.Data to have >=1 event, got %+v", found.Data)
+ }
+ uid := found.Data.Events()[0].Props.Get(ical.PropUID)
+ if uid == nil || uid.Value != "shared1@nidus.test" {
+ t.Fatalf("expected UID shared1@nidus.test in round-tripped event, got %+v", uid)
+ }
+}
+
+// TestIcssubCacheSharedBetweenConsumersInCalDav verifies that one
+// *icssub.Cache shared by two different callers of the same backend does
+// not re-fetch the upstream twice (the second caller sees the cached
+// copy via the singleflight guard), proving the "return cached value and
+// update in the background" design is reachable from the CalDAV API.
+func TestIcssubCacheSharedBetweenConsumersInCalDav(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ // Sleep long enough that *if* the second Get blocked, this
+ // test's wall clock would obviously exceed the bound below.
+ time.Sleep(30 * time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsSample))
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ st, err := store.NewStore(filepath.Join(dir, "data"))
+ if err != nil {
+ t.Fatalf("NewStore: %v", err)
+ }
+ dbase, err := db.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ defer dbase.Close()
+
+ if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
+ t.Fatalf("CreateICSSubscription: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ // One cache, shared by two backends on the same DB — mirrors the
+ // production wiring (cmd/server/main.go) where the CalDAV and web UI
+ // share one instance.
+ shared := icssub.NewCache(time.Hour)
+ b1 := NewBackend(&config.Config{}, st, dbase, logger, shared)
+ b2 := NewBackend(&config.Config{}, st, dbase, logger, shared)
+
+ ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
+
+ if _, err := b1.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
+ t.Fatalf("backend 1 list: %v", err)
+ }
+ after1 := hits.Load()
+ if _, err := b2.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
+ t.Fatalf("backend 2 list: %v", err)
+ }
+ after2 := hits.Load()
+ if after2 > after1+1 {
+ t.Fatalf("expected shared cache to coalesce (hits %d → %d)", after1, after2)
+ }
+}
+
+// TestIcssubCacheStaleReturnsWithBackgroundRefresh verifies the
+// stale-while-revalidate path through the public Cache API: a cached
+// entry past its TTL is still returned immediately (never blocking the
+// caller for the slow 30 ms fetch), while a background refresh updates
+// the entry for the next Get.
+func TestIcssubCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ time.Sleep(50 * time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsSample))
+ }))
+ defer srv.Close()
+
+ c := icssub.NewCache(2 * time.Millisecond) // very short TTL
+ if _, err := c.Get(srv.URL); err != nil {
+ t.Fatalf("first Get: %v", err)
+ }
+ firstHits := hits.Load()
+
+ time.Sleep(5 * time.Millisecond) // force staleness
+
+ st := time.Now()
+ if _, err := c.Get(srv.URL); err != nil {
+ t.Fatalf("stale Get: %v", err)
+ }
+ if time.Since(st) > 40*time.Millisecond {
+ t.Fatalf("stale Get blocked on the network for %v — should have returned the cached copy", time.Since(st))
+ }
+
+ // Wait for the background refresh to land.
+ deadline := time.Now().Add(2 * time.Second)
+ for hits.Load() == firstHits && time.Now().Before(deadline) {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if hits.Load() == firstHits {
+ t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
+ }
+}
+
+// TestICSSubscriptionEventIDMatchesEventInCalDav proves that the object
+// path the CalDAV backend advertises (in ListCalendarObjects) is exactly
+// the same string the EventDetailPage-style lookup would use on the web
+// side — i.e. both surfaces agree on what icssub.EventID(ev) produces.
+func TestICSSubscriptionEventIDMatchesEventInCalDav(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsSample))
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ st, err := store.NewStore(filepath.Join(dir, "data"))
+ if err != nil {
+ t.Fatalf("NewStore: %v", err)
+ }
+ dbase, err := db.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ defer dbase.Close()
+
+ if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
+ t.Fatalf("CreateICSSubscription: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
+ ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
+
+ objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
+ if err != nil || len(objs) == 0 {
+ t.Fatalf("list: objs=%d err=%v", len(objs), err)
+ }
+ gotPath := objs[0].Path
+ wantID := icssub.EventID(mustCalEvent(t, icsSample))
+ wantPath := calObjectPath("holidays", wantID)
+ if gotPath != wantPath {
+ t.Fatalf("CalDAV object path %q does not match web-side EventID-derived %q", gotPath, wantPath)
+ }
+}
diff --git a/internal/icssub/icssub.go b/internal/icssub/icssub.go
index fcbfb8a..9039cae 100644
--- a/internal/icssub/icssub.go
+++ b/internal/icssub/icssub.go
@@ -7,6 +7,8 @@ package icssub
import (
"bytes"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"io"
"net/http"
@@ -19,8 +21,8 @@ import (
"git.arnef.de/arnef/nidus/internal/icalfix"
)
-// DefaultTTL is how long a fetched calendar is cached before being
-// re-fetched on the next access.
+// DefaultTTL is how long a fetched calendar is considered "fresh" before a
+// Get will kick off a background refresh.
const DefaultTTL = 15 * time.Minute
// fetchTimeout bounds how long a single upstream request may take, so one
@@ -32,54 +34,143 @@ const fetchTimeout = 15 * time.Second
// response.
const maxBodySize = 32 * 1024 * 1024 // 32 MiB
+// entry holds everything the Cache knows about a single upstream URL. All
+// fields are only read/written while holding Cache.mu.
type entry struct {
+ url string // original URL as supplied by the caller (fetch normalizes)
+
+ // cal is the most recent successfully-fetched calendar. Nil until the
+ // first successful fetch for this URL.
+ cal *ical.Calendar
+ // lastErr is the most recent fetch error. Set alongside cal == nil
+ // (i.e. no successful fetch yet); cleared the moment a fetch succeeds.
+ lastErr error
+
+ // refreshing is true while a fetch (foreground, or background refresh)
+ // is in flight for this URL.
+ refreshing bool
+ // pending is the completion channel for the in-flight fetch. Only valid
+ // while refreshing is true; it is created fresh for each fetch and
+ // closed exactly once when that fetch finishes. Callers that see
+ // refreshing==true read this channel (under the lock) and wait on it.
+ pending chan struct{}
+
+ // fetchedAt is the wall-clock time of the most recent fetch attempt
+ // (success or failure), used for the TTL freshness check.
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.
+// Cache fetches remote ICS calendars over HTTP(S), keeping a shared
+// in-memory copy per URL. Semantics:
+//
+// - Fresh entry (fetchedAt within TTL): return immediately, no I/O.
+// - Stale entry with a cached copy: return the stale copy immediately
+// AND spawn at most one background refresher (other callers in the
+// same window piggyback on the in-flight refresh).
+// - Stale entry with no cached copy (prior fetch failed): return the
+// cached error immediately AND spawn a background retry.
+// - No entry at all (very first call for this URL): block until a
+// foreground fetch finishes (concurrent first-callers wait on a shared
+// channel and all get the same result) and return its data.
type Cache struct {
ttl time.Duration
client *http.Client
- mu sync.Mutex
- entries map[string]entry
+ mu sync.Mutex
+ urls map[string]*entry // keyed by normalizeURL(url)
}
// 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),
+ ttl: ttl,
+ client: &http.Client{},
+ urls: 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.
+// Get returns the most recently successfully-fetched calendar for url, or
+// the most-recent fetch error if no successful copy exists yet (a
+// background refresher may already be retrying).
func (c *Cache) Get(url string) (*ical.Calendar, error) {
+ key := normalizeURL(url)
+
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
+ e := c.urls[key]
+ if e == nil {
+ e = &entry{url: url}
+ c.urls[key] = e
}
- cal, err := c.fetch(url)
- c.mu.Lock()
- defer c.mu.Unlock()
- if err != nil && ok && e.cal != nil {
- return e.cal, nil
+ switch {
+ case e.cal == nil && e.lastErr == nil && !e.refreshing:
+ // Very first request for this URL: do a foreground fetch.
+ e.refreshing = true
+ e.pending = make(chan struct{})
+ ch := e.pending
+ c.mu.Unlock()
+ go c.doFetch(e, ch)
+ <-ch
+ return c.snapshot(e)
+
+ case e.cal == nil && e.lastErr == nil:
+ // A foreground fetch is already in flight — wait for it.
+ ch := e.pending
+ c.mu.Unlock()
+ <-ch
+ return c.snapshot(e)
+
+ default:
+ // We have some data (a cached copy or a cached error).
+ if time.Since(e.fetchedAt) < c.ttl {
+ // Fresh — just return.
+ c.mu.Unlock()
+ return c.snapshot(e)
+ }
+ // Stale — return the cached value immediately; spawn at most one
+ // background refresher (or piggyback on one already in flight).
+ if !e.refreshing {
+ e.refreshing = true
+ ch := make(chan struct{})
+ e.pending = ch
+ c.mu.Unlock()
+ go c.doFetch(e, ch)
+ } else {
+ c.mu.Unlock()
+ }
+ return c.snapshot(e)
}
- c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err}
- return cal, err
+}
+
+// doFetch performs the network I/O for the entry, updates cal/lastErr and
+// the freshness timestamp under the lock, clears the in-flight state, and
+// closes the per-fetch completion channel exactly once.
+func (c *Cache) doFetch(e *entry, ch chan struct{}) {
+ cal, err := c.fetch(e.url)
+ c.mu.Lock()
+ e.fetchedAt = time.Now()
+ if err == nil {
+ e.cal = cal
+ e.lastErr = nil
+ } else {
+ e.lastErr = err
+ }
+ e.refreshing = false
+ e.pending = nil
+ c.mu.Unlock()
+ close(ch)
+}
+
+// snapshot reads e.cal/e.lastErr under c.mu and returns the same value
+// shape Get does. Callers must not hold c.mu.
+func (c *Cache) snapshot(e *entry) (*ical.Calendar, error) {
+ c.mu.Lock()
+ cal, err := e.cal, e.lastErr
+ c.mu.Unlock()
+ if cal != nil {
+ return cal, nil
+ }
+ return nil, err
}
// fetch downloads and parses url, translating a "webcal://" scheme (used
@@ -128,3 +219,40 @@ func normalizeURL(u string) string {
}
return u
}
+
+// EventID returns a stable, short identifier for a single ical.Event,
+// suitable for use as a filesystem object name or URL path segment. It is
+// the first 32 hex chars (128 bits) of
+// sha256("||") with a ".ics" suffix.
+//
+// Two events with the same DTSTART, same duration, and same SUMMARY hash
+// to the same ID — this matches the addressing scheme used both by the
+// web detail view and the CalDAV backend for ICS-subscription events.
+// Returns "" if DTSTART is missing (not addressable).
+func EventID(ev ical.Event) string {
+ start := ev.Props.Get(ical.PropDateTimeStart)
+ if start == nil {
+ return ""
+ }
+ dur := ""
+ if end := ev.Props.Get(ical.PropDateTimeEnd); end != nil {
+ if s, err := start.DateTime(time.UTC); err == nil {
+ if e, err := end.DateTime(time.UTC); err == nil {
+ dur = e.Sub(s).Round(time.Second).String()
+ }
+ }
+ }
+ summary := ""
+ if p := ev.Props.Get(ical.PropSummary); p != nil {
+ summary = p.Value
+ }
+
+ var keyBuf strings.Builder
+ keyBuf.WriteString(start.Value)
+ keyBuf.WriteRune('|')
+ keyBuf.WriteString(dur)
+ keyBuf.WriteRune('|')
+ keyBuf.WriteString(summary)
+ sum := sha256.Sum256([]byte(keyBuf.String()))
+ return hex.EncodeToString(sum[:16]) + ".ics"
+}
diff --git a/internal/icssub/icssub_test.go b/internal/icssub/icssub_test.go
new file mode 100644
index 0000000..995569b
--- /dev/null
+++ b/internal/icssub/icssub_test.go
@@ -0,0 +1,199 @@
+package icssub
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ ical "github.com/emersion/go-ical"
+)
+
+const sampleICS = "BEGIN:VCALENDAR\r\n" +
+ "VERSION:2.0\r\n" +
+ "PRODID:-//nidus//test//EN\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:ev1@nidus.test\r\n" +
+ "DTSTAMP:20260101T000000Z\r\n" +
+ "DTSTART:20260805T090000Z\r\n" +
+ "DTEND:20260805T100000Z\r\n" +
+ "SUMMARY:Original title\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+
+func mustParseEvent(raw string) ical.Event {
+ cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
+ if err != nil {
+ panic("mustParseEvent: " + err.Error())
+ }
+ evs := cal.Events()
+ if len(evs) == 0 {
+ panic("mustParseEvent: no events")
+ }
+ return evs[0]
+}
+
+// TestCacheFirstFetchPopulatesEntry verifies that the very first Get for a
+// URL does a foreground fetch and returns the parsed calendar.
+func TestCacheFirstFetchPopulatesEntry(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(sampleICS))
+ }))
+ defer srv.Close()
+
+ c := NewCache(time.Hour)
+ cal, err := c.Get(srv.URL)
+ if err != nil {
+ t.Fatalf("first Get: %v", err)
+ }
+ if len(cal.Events()) == 0 {
+ t.Fatalf("expected >=1 event, got 0")
+ }
+}
+
+// TestCacheStaleReturnsWithBackgroundRefresh verifies that once a cached
+// entry is stale, Get keeps returning the *stale* copy immediately while a
+// background refresh is spawned in a separate goroutine (rather than
+// blocking the caller on the network).
+func TestCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ time.Sleep(50 * time.Millisecond) // make the fetch slow enough to
+ w.WriteHeader(http.StatusOK) // observe as background work
+ _, _ = w.Write([]byte(sampleICS))
+ }))
+ defer srv.Close()
+
+ c := NewCache(1 * time.Millisecond) // very short TTL
+ if _, err := c.Get(srv.URL); err != nil {
+ t.Fatalf("first Get: %v", err)
+ }
+ firstHits := hits.Load()
+
+ // Force staleness.
+ time.Sleep(3 * time.Millisecond)
+
+ // Subsequent Get must return the stale copy immediately without waiting
+ // for the slow server to respond — if it blocked, this call would take
+ // >= 50ms, which we bound with a deadline below via the hit counter.
+ st := time.Now()
+ cal, err := c.Get(srv.URL)
+ if err != nil {
+ t.Fatalf("second Get: %v", err)
+ }
+ if len(cal.Events()) == 0 {
+ t.Fatalf("expected event in stale response")
+ }
+ if time.Since(st) > 40*time.Millisecond {
+ t.Fatalf("second Get appears to have blocked on the network for %v", time.Since(st))
+ }
+
+ // Wait for the background refresh to complete.
+ deadline := time.Now().Add(2 * time.Second)
+ for hits.Load() == firstHits && time.Now().Before(deadline) {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if hits.Load() == firstHits {
+ t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
+ }
+}
+
+// TestCacheConcurrentFirstFetchCoalesce ensures N concurrent first calls
+// for the same URL coalesce to a small number of actual origin fetches.
+func TestCacheConcurrentFirstFetchCoalesce(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ time.Sleep(40 * time.Millisecond) // widen the coalescing window
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(sampleICS))
+ }))
+ defer srv.Close()
+
+ c := NewCache(time.Hour)
+
+ const n = 8
+ errs := make([]error, n)
+ var wg sync.WaitGroup
+ start := make(chan struct{})
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ <-start
+ _, err := c.Get(srv.URL)
+ errs[i] = err
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+ for _, err := range errs {
+ if err != nil {
+ t.Fatalf("goroutine error: %v", err)
+ }
+ }
+ // Without coalescing we'd see n=8 hits; with it we expect a small
+ // constant (the first caller fetches; the rest either return the
+ // in-progress result or kick off a coalesced background refresh).
+ if hits.Load() > 3 {
+ t.Fatalf("expected coalescing to reduce fetches (got %d)", hits.Load())
+ }
+}
+
+// TestEventIDStable verifies that the same event hashes to the same ID
+// across calls, and is the right shape (32 hex chars + ".ics").
+func TestEventIDStable(t *testing.T) {
+ ev := mustParseEvent(sampleICS)
+ id1 := EventID(ev)
+ id2 := EventID(ev)
+ if id1 != id2 {
+ t.Fatalf("EventID not stable: %q vs %q", id1, id2)
+ }
+ if !strings.HasSuffix(id1, ".ics") {
+ t.Fatalf("EventID should end in .ics, got %q", id1)
+ }
+ if len(id1) != 32+4 {
+ t.Fatalf("expected 32 hex chars + .ics, got %q (len %d)", id1, len(id1))
+ }
+}
+
+// TestEventIDDiffersForDifferentEvents verifies that two events with
+// different titles or different start times hash to different IDs, while
+// two events with only a different UID hash to the same ID.
+func TestEventIDDiffersForDifferentEvents(t *testing.T) {
+ base := EventID(mustParseEvent(sampleICS))
+
+ // Different title.
+ rawA := strings.Replace(sampleICS, "SUMMARY:Original title", "SUMMARY:Other title", 1)
+ if EventID(mustParseEvent(rawA)) == base {
+ t.Fatal("same start, different title must hash differently")
+ }
+
+ // Different start time.
+ rawB := strings.Replace(sampleICS, "DTSTART:20260805T090000Z", "DTSTART:20260806T090000Z", 1)
+ if EventID(mustParseEvent(rawB)) == base {
+ t.Fatal("different start times must hash differently")
+ }
+
+ // Same start, same title, different UID → same hash.
+ rawC := strings.Replace(sampleICS, "UID:ev1@nidus.test", "UID:ev2@nidus.test", 1)
+ if EventID(mustParseEvent(rawC)) != base {
+ t.Fatal("UID should not affect the hash — same start/dur/title must be equal")
+ }
+}
+
+// TestEventIDWorksWithoutUID verifies EventID still produces a value (from
+// DTSTART+DTEND+SUMMARY) when the event has no UID property.
+func TestEventIDWorksWithoutUID(t *testing.T) {
+ raw := strings.Replace(sampleICS, "UID:ev1@nidus.test\r\n", "", 1)
+ ev := mustParseEvent(raw)
+ id := EventID(ev)
+ if id == "" {
+ t.Fatal("EventID should be non-empty even without UID")
+ }
+}
diff --git a/internal/web/calendar.go b/internal/web/calendar.go
index fb19c32..b5898fc 100644
--- a/internal/web/calendar.go
+++ b/internal/web/calendar.go
@@ -18,6 +18,7 @@ import (
"git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
+ "git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical"
@@ -650,12 +651,17 @@ func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
ref := r.PathValue("ref")
id := r.PathValue("id")
+ // For ICS subscriptions, ref is "!" and id is the stable
+ // icssub.EventID (already ".ics"-suffixed). For regular calendars,
+ // ref is a bare name or "owner~name" and id is a ".ics" filename
+ // from the eventIDRe character set. Both shapes share the same
+ // "hex/alnum .ics" shape, so we only need the regex check.
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
color, form, err := s.eventForDisplay(username, ref, id)
- if errors.Is(err, store.ErrNotFound) {
+ if errors.Is(err, store.ErrNotFound) || errors.Is(err, errCalendarNotFound) {
http.NotFound(w, r)
return
}
@@ -670,8 +676,12 @@ func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
// eventForDisplay resolves a calendar reference for read access, loads and
// decodes the event with the given id, populates the form's display fields
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
-// the detail view's header dot.
+// the detail view's header dot. It handles both ordinary (stored) events
+// and ICS-subscription events (ref "!").
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
+ if strings.HasPrefix(ref, icsRefPrefix) {
+ return s.icsEventForDisplay(username, ref, id)
+ }
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
return "", form, err
@@ -701,6 +711,35 @@ func (s *Server) eventForDisplay(username, ref, id string) (color string, form t
return color, form, nil
}
+// icsEventForDisplay resolves one of username's ICS subscriptions named
+// ref[len(!):] and looks up the event whose icssub.EventID hashes to id.
+// Returns the subscription's display color and a form with Writable=false.
+func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
+ name := strings.TrimPrefix(ref, icsRefPrefix)
+ sub, err := s.dbase.GetICSSubscription(username, name)
+ if err != nil {
+ return "", form, errCalendarNotFound
+ }
+ cal, err := s.icsCache.Get(sub.URL)
+ if err != nil {
+ return "", form, err
+ }
+ for _, ev := range cal.Events() {
+ if icssub.EventID(ev) != id {
+ continue
+ }
+ form, err = eventFormFromComponent(id, ev)
+ if err != nil {
+ return "", form, err
+ }
+ form.CalRef = ref
+ form.CalendarLabel = name
+ form.Writable = false
+ return sub.Color, form, nil
+ }
+ return "", form, errCalendarNotFound
+}
+
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
ref := r.PathValue("ref")
@@ -1264,11 +1303,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
}
// 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 ("#").
+// which uses stale-while-revalidate so a month-view render never blocks on
+// network I/O) and places each VEVENT's occurrence onto the month grid,
+// the same way a stored calendar object would be. Each event's ID is the
+// stable icssub.EventID hash, which routes through the read-only detail
+// page (eventForDisplay → icsEventForDisplay).
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 {
@@ -1276,8 +1315,11 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
}
const totalDays = 42
- for i, ev := range cal.Events() {
- id := fmt.Sprintf("ics-%d", i)
+ for _, ev := range cal.Events() {
+ id := icssub.EventID(ev)
+ if id == "" {
+ continue
+ }
form, err := eventFormFromComponent(id, ev)
if err != nil {
continue
@@ -1311,7 +1353,6 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
- LinkURL: "#",
})
}
}
diff --git a/internal/web/ics_detail_test.go b/internal/web/ics_detail_test.go
new file mode 100644
index 0000000..d575e51
--- /dev/null
+++ b/internal/web/ics_detail_test.go
@@ -0,0 +1,176 @@
+package web
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "git.arnef.de/arnef/nidus/internal/config"
+ "git.arnef.de/arnef/nidus/internal/db"
+ "git.arnef.de/arnef/nidus/internal/icssub"
+ "git.arnef.de/arnef/nidus/internal/store"
+ ical "github.com/emersion/go-ical"
+)
+
+// icsDetailSample is a minimal valid ICS feed with one VEVENT on Aug 5, 2026.
+const icsDetailSample = "BEGIN:VCALENDAR\r\n" +
+ "VERSION:2.0\r\n" +
+ "PRODID:-//nidus//test//EN\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "UID:detail1@nidus.test\r\n" +
+ "DTSTAMP:20260101T000000Z\r\n" +
+ "DTSTART:20260805T090000Z\r\n" +
+ "DTEND:20260805T100000Z\r\n" +
+ "SUMMARY:ICS detail event\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+
+// newServerWithICSCache builds a Server wired to an upstream ICS server
+// (for deterministic tests) and returns it, sharing one icssub.Cache the
+// same way cmd/server/main.go does in production.
+func newServerWithICSCache(t *testing.T, upstreamURL string) *Server {
+ t.Helper()
+ dir := t.TempDir()
+ st, err := store.NewStore(filepath.Join(dir, "data"))
+ if err != nil {
+ t.Fatalf("NewStore: %v", err)
+ }
+ d, err := db.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ cfg := &config.Config{}
+ if err := d.CreateUser("alice", "password", "", ""); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := d.CreateICSSubscription("alice", "holidays", upstreamURL, "#123abc"); err != nil {
+ t.Fatalf("CreateICSSubscription: %v", err)
+ }
+ shared := icssub.NewCache(time.Hour)
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ return NewServer(cfg, st, d, logger, shared)
+}
+
+// TestICSDetailRouteRendersReadonlyEvent verifies the full ICS detail path:
+// the user clicks an ICS event from the month/week grid, the browser lands
+// on GET /calendar/!holidays//, and the handler returns 200
+// with the event's fields rendered and NO Edit link (ICS subs are read-only).
+func TestICSDetailRouteRendersReadonlyEvent(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsDetailSample))
+ }))
+ defer srv.Close()
+
+ s := newServerWithICSCache(t, srv.URL)
+ handler := s.Handler(emptyStaticFS{})
+ cookie := loginAs(t, handler, "alice", "password")
+
+ id := icssub.EventID(mustParseICS(t, icsDetailSample))
+ if id == "" {
+ t.Fatal("EventID must be non-empty")
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/calendar/"+url.PathEscape("!holidays")+"/"+id, nil)
+ req.AddCookie(cookie)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ for _, want := range []string{
+ "ICS detail event", // summary
+ "holidays", // calendar label
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("expected body to contain %q, got:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, ">Edit") {
+ t.Errorf("Edit link should not be present on a read-only ICS detail page, got:\n%s", body)
+ }
+}
+
+// TestICSDetailRouteBadIDReturns404 verifies a request with a malformed
+// event ID (missing .ics suffix) gets a 404 rather than rendering.
+func TestICSDetailRouteBadIDReturns404(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(icsDetailSample))
+ }))
+ defer srv.Close()
+
+ s := newServerWithICSCache(t, srv.URL)
+ handler := s.Handler(emptyStaticFS{})
+ cookie := loginAs(t, handler, "alice", "password")
+
+ req := httptest.NewRequest(http.MethodGet, "/calendar/!holidays/not-a-valid-id", nil)
+ req.AddCookie(cookie)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404 for malformed event id, got %d", rr.Code)
+ }
+}
+
+// TestMonthGridLinksICSEventToDetailPage exercises the full user path: the
+// month/week grid renders an ICS-subscription event as a link to a detail
+// page (not "#" and not a direct edit URL).
+func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(icsDetailSample))
+ }))
+ defer srv.Close()
+
+ s := newServerWithICSCache(t, srv.URL)
+ handler := s.Handler(emptyStaticFS{})
+ cookie := loginAs(t, handler, "alice", "password")
+
+ req := httptest.NewRequest(http.MethodGet, "/calendar?year=2026&month=8", nil)
+ req.AddCookie(cookie)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "ICS detail event") {
+ t.Fatalf("month grid should list ICS events, got:\n%s", body)
+ }
+ id := icssub.EventID(mustParseICS(t, icsDetailSample))
+ // The grid renders the detail link with a "/web/calendar/![/"
+ // shape (see eventLinkURL in calendar.templ). The "!" in the ref is
+ // not %-escaped by templ.URL — we observed un-escaped output in the
+ // rendered HTML and pin the exact shape below.
+ wantHref := "href=\"/web/calendar/!holidays/" + id + "\""
+ if !strings.Contains(body, wantHref) {
+ t.Fatalf("month grid should link ICS event to %q, but did not", wantHref)
+ }
+ if strings.Contains(body, `href="#"`) {
+ t.Fatalf("ICS events should not link to '#'")
+ }
+}
+
+// mustParseICS parses raw and returns its first VEVENT (panic on error).
+func mustParseICS(t *testing.T, raw string) ical.Event {
+ t.Helper()
+ cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
+ if err != nil {
+ t.Fatalf("ical decode: %v", err)
+ }
+ evs := cal.Events()
+ if len(evs) == 0 {
+ t.Fatal("no events in ICS")
+ }
+ return evs[0]
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index cacbb5b..4dc270a 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -25,9 +25,16 @@ type Server struct {
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, icsCache: icssub.NewCache(icssub.DefaultTTL)}
+// NewServer constructs a web UI Server. icsCache may be nil, in which
+// case a private default-TTL cache is created — prefer sharing a single
+// *icssub.Cache with the CALDAV/CardDAV backends (e.g. from
+// cmd/server/main.go) so the web calendar page and the DAV protocol
+// serve identical events for the same ICS subscription.
+func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Server {
+ if icsCache == nil {
+ icsCache = icssub.NewCache(icssub.DefaultTTL)
+ }
+ return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache}
}
// Handler returns the http.Handler serving the web UI, mounted at "/web/"
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index baee070..6b0f228 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -47,7 +47,7 @@ func newTestServer(t *testing.T) *Server {
t.Fatalf("CreateCalendar bob/personal: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- return NewServer(cfg, st, dbase, logger)
+ return NewServer(cfg, st, dbase, logger, nil)
}
// loginAs performs a login request against handler and returns the
]