feat(web): show ICS subscription events in detail view
This commit is contained in:
@@ -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}}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
||||
@@ -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(
|
||||
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
|
||||
|
||||
+22
-26
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user