2 Commits
Author SHA1 Message Date
arnef f34041d889 feat(web): show ICS subscription events in detail view 2026-09-02 20:02:48 +02:00
arnef e18c83b618 feat(web): add calendar event detail view 2026-09-01 19:10:17 +02:00
16 changed files with 1931 additions and 404 deletions
+9 -2
View File
@@ -17,6 +17,7 @@ import (
"git.arnef.de/arnef/nidus/internal/carddav" "git.arnef.de/arnef/nidus/internal/carddav"
"git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db" "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/store"
"git.arnef.de/arnef/nidus/internal/web" "git.arnef.de/arnef/nidus/internal/web"
filewebdav "git.arnef.de/arnef/nidus/internal/webdav" filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
@@ -103,10 +104,16 @@ func main() {
authMw := auth.NewMiddleware(cfg, dbase, logger) authMw := auth.NewMiddleware(cfg, dbase, logger)
// ---- Handlers ---- // ---- 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) cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, 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) mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger)
+12 -6
View File
@@ -41,10 +41,16 @@ type Backend struct {
icsCache *icssub.Cache icsCache *icssub.Cache
} }
// NewBackend creates a CalDAV backend. dbase may be nil, in which case // NewBackend creates a CalDAV backend over an existing ICS cache.
// calendar sharing is disabled (only a user's own calendars are visible). // dbase may be nil, in which case calendar sharing is disabled (only a
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend { // user's own calendars are visible). The icsCache is shared with any
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} // 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. // 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 // property into PROPFIND responses for calendar collections, since
// go-webdav's caldav.Backend interface has no extension point for // go-webdav's caldav.Backend interface has no extension point for
// vendor-specific WebDAV properties. // vendor-specific WebDAV properties.
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { 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) b := NewBackend(cfg, st, dbase, logger, icsCache)
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}} return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
} }
+3 -3
View File
@@ -46,7 +46,7 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
t.Fatalf("CreateCalendar bob/personal: %v", err) t.Fatalf("CreateCalendar bob/personal: %v", err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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 { func ctxFor(username string) context.Context {
@@ -179,7 +179,7 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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( req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`)) `<?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)) 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( 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/">`+ `<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
+22 -26
View File
@@ -1,8 +1,6 @@
package caldav package caldav
import ( import (
"crypto/sha1"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -13,6 +11,7 @@ import (
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icssub"
) )
// defaultICSColor is the display color for an ICS/webcal subscription // 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: // 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 // its own UID property if it has one, otherwise a placeholder derived from
// position so it still round-trips consistently between requests. // the object ID (so the event still has a *unique* UID in the returned
// VCALENDAR).
func icsObjectUID(ev ical.Event, fallback string) string { func icsObjectUID(ev ical.Event, fallback string) string {
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" { if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
return p.Value return p.Value
@@ -41,16 +41,11 @@ func icsObjectUID(ev ical.Event, fallback string) string {
return fallback return fallback
} }
// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to // icsSubscriptionCalendarObjects fetches sub's remote calendar (via
// address a fetched VEVENT within its subscription calendar, derived from // b.icsCache) and returns one caldav.CalendarObject per VEVENT. The
// its UID so it stays stable across fetches of the same feed. // object path is derived from icssub.EventID(ev) so the same event keeps
func icsObjID(uid string) string { // the same path across fetches, even if its position in the document
sum := sha1.Sum([]byte(uid)) // changes.
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) { func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
cal, err := b.icsCache.Get(sub.URL) cal, err := b.icsCache.Get(sub.URL)
if err != nil { if err != nil {
@@ -58,8 +53,8 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
} }
var objs []caldav.CalendarObject var objs []caldav.CalendarObject
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) obj, err := b.encodeICSObject(localName, ev)
if err != nil { if err != nil {
continue continue
} }
@@ -75,24 +70,25 @@ func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.
if err != nil { if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err)) return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
} }
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i)) if icssub.EventID(ev) != objID {
if icsObjID(uid) != objID {
continue 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")) return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
} }
// encodeICSObject wraps a single fetched VEVENT ev into its own // encodeICSObject wraps a single fetched VEVENT ev into its own
// caldav.CalendarObject, encoding it as a standalone one-event calendar // caldav.CalendarObject, encoding it as a standalone one-event calendar
// the same way every other calendar object in this backend is // the same way every other calendar object in this backend is represented.
// represented. fallbackUID is used to derive the object ID/UID if ev has func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) {
// no UID property of its own. objID := icssub.EventID(ev)
func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) { if objID == "" {
uid := icsObjectUID(ev, fallbackUID) // Not addressable (no DTSTART) — skip.
objID := icsObjID(uid) return nil, fmt.Errorf("event has no DTSTART; not addressable")
}
uid := icsObjectUID(ev, objID)
event := ical.NewEvent() event := ical.NewEvent()
event.Props = ev.Props event.Props = ev.Props
+268
View File
@@ -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)
}
}
+157 -29
View File
@@ -7,6 +7,8 @@ package icssub
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -19,8 +21,8 @@ import (
"git.arnef.de/arnef/nidus/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
) )
// DefaultTTL is how long a fetched calendar is cached before being // DefaultTTL is how long a fetched calendar is considered "fresh" before a
// re-fetched on the next access. // Get will kick off a background refresh.
const DefaultTTL = 15 * time.Minute const DefaultTTL = 15 * time.Minute
// fetchTimeout bounds how long a single upstream request may take, so one // fetchTimeout bounds how long a single upstream request may take, so one
@@ -32,54 +34,143 @@ const fetchTimeout = 15 * time.Second
// response. // response.
const maxBodySize = 32 * 1024 * 1024 // 32 MiB 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 { 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 fetchedAt time.Time
cal *ical.Calendar
err error
} }
// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived // Cache fetches remote ICS calendars over HTTP(S), keeping a shared
// in-memory copy per URL so repeated renders (e.g. every month-view page // in-memory copy per URL. Semantics:
// load, or CalDAV client polling) don't re-fetch the same subscription //
// from origin every time. // - 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 { type Cache struct {
ttl time.Duration ttl time.Duration
client *http.Client client *http.Client
mu sync.Mutex mu sync.Mutex
entries map[string]entry urls map[string]*entry // keyed by normalizeURL(url)
} }
// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure). // NewCache creates a Cache with the given TTL (use DefaultTTL if unsure).
func NewCache(ttl time.Duration) *Cache { func NewCache(ttl time.Duration) *Cache {
return &Cache{ return &Cache{
ttl: ttl, ttl: ttl,
client: &http.Client{Timeout: fetchTimeout}, client: &http.Client{},
entries: make(map[string]entry), urls: make(map[string]*entry),
} }
} }
// Get returns the parsed calendar fetched from url, using a cached copy // Get returns the most recently successfully-fetched calendar for url, or
// if it's still within the TTL. If a fresh fetch fails but a previously // the most-recent fetch error if no successful copy exists yet (a
// fetched copy exists, the stale copy is returned instead of the error, // background refresher may already be retrying).
// so a transient network issue doesn't blank out the calendar entirely.
func (c *Cache) Get(url string) (*ical.Calendar, error) { func (c *Cache) Get(url string) (*ical.Calendar, error) {
key := normalizeURL(url)
c.mu.Lock() c.mu.Lock()
e, ok := c.entries[url] e := c.urls[key]
fresh := ok && time.Since(e.fetchedAt) < c.ttl if e == nil {
c.mu.Unlock() e = &entry{url: url}
if fresh { c.urls[key] = e
return e.cal, e.err
} }
cal, err := c.fetch(url) switch {
c.mu.Lock() case e.cal == nil && e.lastErr == nil && !e.refreshing:
defer c.mu.Unlock() // Very first request for this URL: do a foreground fetch.
if err != nil && ok && e.cal != nil { e.refreshing = true
return e.cal, nil 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 // fetch downloads and parses url, translating a "webcal://" scheme (used
@@ -128,3 +219,40 @@ func normalizeURL(u string) string {
} }
return u 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("<DTSTART-value>|<duration>|<SUMMARY>") 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"
}
+199
View File
@@ -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")
}
}
+114 -8
View File
@@ -18,6 +18,8 @@ import (
"git.arnef.de/arnef/nidus/internal/birthdays" "git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix" "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" "git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
) )
@@ -636,6 +638,108 @@ func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
} }
} }
// handleEventView renders the read-only detail view for a single event. It
// is the landing page when a user clicks an event in the month/week grid;
// writable calendars link from here to the edit page.
func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
// For ICS subscriptions, ref is "!<name>" 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 "<hex>.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) || errors.Is(err, errCalendarNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.handleCalRefError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventDetail(username, templates.EventDetailData{Form: form, Color: color}).Render(context.Background(), w)
}
// 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. It handles both ordinary (stored) events
// and ICS-subscription events (ref "!<name>").
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
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
return "", form, err
}
form, err = eventFormFromICS(id, data)
if err != nil {
s.logger.Error("decoding event", "error", err)
return "", form, err
}
form.CalRef = ref
label := name
if owner != username {
label = name + " (" + s.dbase.DisplayName(owner) + ")"
}
form.CalendarLabel = label
form.Writable = owner == username
if !form.Writable {
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
form.Writable = share.Permission == db.PermWrite
}
}
color, _ = s.dbase.GetCalendarColor(owner, name)
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) { func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
ref := r.PathValue("ref") ref := r.PathValue("ref")
@@ -1199,11 +1303,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
} }
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache, // 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 // which uses stale-while-revalidate so a month-view render never blocks on
// from origin) and places each VEVENT's occurrence onto the month grid, // network I/O) and places each VEVENT's occurrence onto the month grid,
// the same way a stored calendar object would be. There's no per-event // the same way a stored calendar object would be. Each event's ID is the
// edit page for these (the source is external and read-only), so each // stable icssub.EventID hash, which routes through the read-only detail
// event's LinkURL is left pointing nowhere useful ("#"). // page (eventForDisplay → icsEventForDisplay).
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error { 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) cal, err := s.icsCache.Get(entry.ICSURL)
if err != nil { if err != nil {
@@ -1211,8 +1315,11 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
} }
const totalDays = 42 const totalDays = 42
for i, ev := range cal.Events() { for _, ev := range cal.Events() {
id := fmt.Sprintf("ics-%d", i) id := icssub.EventID(ev)
if id == "" {
continue
}
form, err := eventFormFromComponent(id, ev) form, err := eventFormFromComponent(id, ev)
if err != nil { if err != nil {
continue continue
@@ -1246,7 +1353,6 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
Summary: form.Summary, Summary: form.Summary,
TimeText: timeText, TimeText: timeText,
AllDay: form.AllDay, AllDay: form.AllDay,
LinkURL: "#",
}) })
} }
} }
+209
View File
@@ -0,0 +1,209 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.arnef.de/arnef/nidus/internal/db"
)
// seedEvent stores an all-day event (one-day span) in owner's calendar cal
// under object id, with the given summary/location/description. Used to set
// up events that handleEventView should render.
func seedEvent(t *testing.T, s *Server, owner, cal, id, summary, location, description string) {
t.Helper()
data := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:" + strings.TrimSuffix(id, ".ics") + "\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"SUMMARY:" + summary + "\r\n" +
"LOCATION:" + location + "\r\n" +
"DESCRIPTION:" + description + "\r\n" +
"DTSTART;VALUE=DATE:20260805\r\n" +
"DTEND;VALUE=DATE:20260806\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
if err := s.store.PutObject(owner, "cal-"+cal, id, []byte(data)); err != nil {
t.Fatalf("PutObject: %v", err)
}
}
// getEventDetail issues GET /calendar/{ref}/{id} as the session identified by
// cookie and returns the recorded response.
func getEventDetail(t *testing.T, handler http.Handler, cookie *http.Cookie, ref, id string) *httptest.ResponseRecorder {
t.Helper()
path := "/calendar/" + url.PathEscape(ref) + "/" + url.PathEscape(id)
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
return rr
}
func TestEventDetailShowsOwnEvent(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := "aabbccddeeff.ics"
seedEvent(t, s, "alice", "work", id, "Team standup", "Meetroom A", "Daily sync with the team")
rr := getEventDetail(t, handler, cookie, "work", id)
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{
"Team standup", // summary / title
"Meetroom A", // location
"Daily sync with the team", // description
"work", // calendar label
">Edit", // writable → Edit link present
"Export .ics",
} {
if !strings.Contains(body, want) {
t.Errorf("expected body to contain %q, got:\n%s", want, body)
}
}
}
func TestEventDetailReadOnlySharedHasNoEdit(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
aliceCookie := loginAs(t, handler, "alice", "password")
bobCookie := loginAs(t, handler, "bob", "password")
id := "123456.ics"
seedEvent(t, s, "bob", "personal", id, "Bob lunch", "Cafe", "Lunch plans")
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermRead); err != nil {
t.Fatalf("ShareCalendar: %v", err)
}
// alice (read share) can view but not edit.
rr := getEventDetail(t, handler, aliceCookie, "bob~personal", id)
if rr.Code != http.StatusOK {
t.Fatalf("alice view: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "Bob lunch") {
t.Errorf("alice: expected body to contain summary, got:\n%s", body)
}
if !strings.Contains(body, "shared with you as read-only") {
t.Errorf("alice: expected read-only notice, got:\n%s", body)
}
if strings.Contains(body, ">Edit") {
t.Errorf("alice: Edit link should not be present on a read-only share, got:\n%s", body)
}
// bob (owner) can still see the Edit link.
rrBob := getEventDetail(t, handler, bobCookie, "personal", id)
if rrBob.Code != http.StatusOK {
t.Fatalf("bob view own: expected 200, got %d", rrBob.Code)
}
if !strings.Contains(rrBob.Body.String(), ">Edit") {
t.Errorf("bob: expected Edit link, got:\n%s", rrBob.Body.String())
}
}
func TestEventDetailWriteShareHasEditLink(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
seedEvent(t, s, "bob", "personal", "a1b2c3.ics", "Bob meeting", "Office", "Sync")
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermWrite); err != nil {
t.Fatalf("ShareCalendar: %v", err)
}
rr := getEventDetail(t, handler, cookie, "bob~personal", "a1b2c3.ics")
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, ">Edit") {
t.Errorf("write share should expose Edit link, got:\n%s", body)
}
if strings.Contains(body, "shared with you as read-only") {
t.Errorf("write share should not show read-only notice, got:\n%s", body)
}
}
func TestEventDetailNotFound(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
// Valid calendar, missing object.
if rr := getEventDetail(t, handler, cookie, "work", "doesnotexist.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("missing event: expected 404, got %d", rr.Code)
}
// Unknown calendar ref.
if rr := getEventDetail(t, handler, cookie, "does_not_exist", "0000.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("unknown calendar: expected 404, got %d", rr.Code)
}
// Invalid id shape (rejected by eventIDRe before store access).
if rr := getEventDetail(t, handler, cookie, "work", "bad/../etc/passwd.ics"); rr.Code != http.StatusNotFound {
t.Fatalf("invalid id: expected 404, got %d", rr.Code)
}
}
func TestEventDetailRequiresLogin(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
req := httptest.NewRequest(http.MethodGet, "/calendar/work/anything.ics", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("expected redirect to login, got %d", rr.Code)
}
}
func TestEventDetailRejectsNonGet(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
req := httptest.NewRequest(http.MethodPost, "/calendar/work/anything.ics", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for POST, got %d", rr.Code)
}
}
// TestEditRouteStillResolves guards against the new detail route
// (/calendar/{ref}/{id}) shadowing the more specific edit/delete/export
// routes under the same {ref}+{id} prefix in Go's ServeMux.
func TestEditRouteStillResolves(t *testing.T) {
s := newTestServer(t)
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := "cafebabe00.ics"
seedEvent(t, s, "alice", "work", id, "Edit me", "Room", "Note")
// GET the edit form — must still hit handleEventEdit, not the detail view.
req := httptest.NewRequest(http.MethodGet, "/calendar/work/cafebabe00.ics/edit", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("edit GET: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "Edit event") || !strings.Contains(rr.Body.String(), "<form") {
t.Fatalf("expected the edit form to render, got:\n%s", rr.Body.String())
}
// The delete route still works (redirect to /web/calendar on success).
req = httptest.NewRequest(http.MethodPost, "/calendar/work/cafebabe00.ics/delete", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("delete: expected 303 redirect, got %d: %s", rr.Code, rr.Body.String())
}
}
+176
View File
@@ -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/<icssub.EventID>/, 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/!<ref>/<id>"
// 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]
}
+11 -3
View File
@@ -25,9 +25,16 @@ type Server struct {
icsCache *icssub.Cache icsCache *icssub.Cache
} }
// NewServer constructs a web UI Server. // NewServer constructs a web UI Server. icsCache may be nil, in which
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server { // case a private default-TTL cache is created — prefer sharing a single
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} // *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/" // Handler returns the http.Handler serving the web UI, mounted at "/web/"
@@ -65,6 +72,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth)) mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth))
mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek)) mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek))
mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew)) mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew))
mux.HandleFunc("/calendar/{ref}/{id}", s.requireLogin(s.handleEventView))
mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport)) mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport))
mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll)) mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll))
mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit)) mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit))
+1 -1
View File
@@ -47,7 +47,7 @@ func newTestServer(t *testing.T) *Server {
t.Fatalf("CreateCalendar bob/personal: %v", err) t.Fatalf("CreateCalendar bob/personal: %v", err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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 // loginAs performs a login request against handler and returns the
+126 -4
View File
@@ -3,6 +3,7 @@ package templates
import "fmt" import "fmt"
import "strconv" import "strconv"
import "strings" import "strings"
import "time"
// CalendarSummary is one calendar (own or shared) shown in the combined // CalendarSummary is one calendar (own or shared) shown in the combined
// month view's legend. // month view's legend.
@@ -24,9 +25,9 @@ type EventSummary struct {
Summary string Summary string
TimeText string // e.g. "14:00" or "" for all-day events TimeText string // e.g. "14:00" or "" for all-day events
AllDay bool AllDay bool
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit" // LinkURL overrides the default "/web/calendar/{CalRef}/{ID}" (detail
// link target, used by virtual/read-only calendars (e.g. birthdays) // view) link target, used by virtual/read-only calendars (e.g.
// that don't have an editable event object of their own. // birthdays) that don't have an editable event object of their own.
LinkURL string LinkURL string
} }
@@ -100,6 +101,14 @@ type EventFormData struct {
EndTime string // "HH:MM", empty when AllDay EndTime string // "HH:MM", empty when AllDay
} }
// EventDetailData is everything the read-only event detail view needs: the
// decoded event itself (Form, including its ID/CalRef/Writable display
// metadata) plus the calendar's color for the header dot.
type EventDetailData struct {
Form EventFormData
Color string // calendar color, "" if unset
}
templ MonthView(username string, data MonthViewData) { templ MonthView(username string, data MonthViewData) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap"> <div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
@@ -301,7 +310,7 @@ func eventLinkURL(ev EventSummary) string {
if ev.LinkURL != "" { if ev.LinkURL != "" {
return ev.LinkURL return ev.LinkURL
} }
return "/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit" return "/web/calendar/" + ev.CalRef + "/" + ev.ID
} }
// eventTextColor returns a color derived from hex, darkened if needed so // eventTextColor returns a color derived from hex, darkened if needed so
@@ -355,6 +364,119 @@ func clampByte(v float64) int {
// EventDetail is the read-only detail view for a single event, opened when
// the user clicks an event in the month or week grid. It shows the
// event's fields and, when the calendar is writable, offers an Edit link.
templ EventDetail(username string, data EventDetailData) {
@Layout("Calendar", username) {
<div class="flex items-center justify-between gap-3 flex-wrap mb-6">
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>
<div class="flex items-center gap-3">
if data.Form.Writable {
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/edit") }
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Edit
</a>
}
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/export") }
class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
Export .ics
</a>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-6 max-w-2xl">
<div class="flex items-start gap-3 border-b border-gray-100 pb-5 mb-5">
<span class="w-3 h-3 rounded-full mt-2 shrink-0 ring-1 ring-inset ring-black/10" style={ "background-color: " + colorOrDefault(data.Color) }></span>
<div class="min-w-0">
<h1 class="text-2xl font-semibold break-words leading-tight">{ data.Form.Summary }</h1>
<p class="text-sm text-gray-500 mt-1">{ data.Form.CalendarLabel }</p>
</div>
</div>
if !data.Form.Writable {
<p class="mb-5 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
This calendar was shared with you as read-only you can view this event but not change it.
</p>
}
<dl class="space-y-4">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">When</dt>
<dd class="text-base text-gray-900">{ eventRangeText(data.Form) }</dd>
</div>
if data.Form.Location != "" {
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Location</dt>
<dd class="text-base text-gray-900 break-words">{ data.Form.Location }</dd>
</div>
}
if data.Form.Description != "" {
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Description</dt>
<dd class="text-base text-gray-900 whitespace-pre-wrap break-words">{ data.Form.Description }</dd>
</div>
}
</dl>
</div>
}
}
// eventRangeText renders a human-readable "when" string for an event:
// a single timed event reads "Aug 5, 2026, 14:00 15:00"; a multi-day
// range reads "Aug 5 7, 2026"; a single all-day date reads
// "August 5, 2026".
func eventRangeText(f EventFormData) string {
if f.AllDay {
if f.StartDate != f.EndDate {
s, err := dateOnly(f.StartDate)
if err != nil {
return f.StartDate
}
e, err := dateOnly(f.EndDate)
if err != nil {
return f.EndDate
}
if s.Year() == e.Year() && s.Month() == e.Month() {
return fmt.Sprintf("%s %s, %d", s.Format("Jan 2"), e.Format("2"), s.Year())
}
return fmt.Sprintf("%s %s", s.Format("Jan 2, 2006"), e.Format("Jan 2, 2006"))
}
d, err := dateOnly(f.StartDate)
if err != nil {
return f.StartDate
}
return d.Format("January 2, 2006")
}
s, err := dateTime(f.StartDate, f.StartTime)
if err != nil {
return f.StartDate
}
e, err := dateTime(f.EndDate, f.EndTime)
if err != nil {
return s.Format("January 2, 2006, 15:04")
}
if s.Day() == e.Day() {
return fmt.Sprintf("%s, %s %s", s.Format("January 2, 2006"), s.Format("15:04"), e.Format("15:04"))
}
if s.Year() == e.Year() && s.Month() == e.Month() {
return fmt.Sprintf("%s %s, %d", s.Format("Jan 2, 15:04"), e.Format("15:04"), s.Year())
}
return fmt.Sprintf("%s %s", s.Format("Jan 2, 2006, 15:04"), e.Format("Jan 2, 2006, 15:04"))
}
func dateOnly(ds string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", ds, time.Local)
}
func dateTime(ds, ts string) (time.Time, error) {
if ts == "" {
return time.ParseInLocation("2006-01-02", ds, time.Local)
}
return time.ParseInLocation("2006-01-02T15:04", ds+"T"+ts, time.Local)
}
templ EventForm(username string, data EventFormData, errMsg string) { templ EventForm(username string, data EventFormData, errMsg string) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a> <a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>
File diff suppressed because it is too large Load Diff
+48
View File
@@ -33,6 +33,54 @@ func TestEventTextColorDarkensLightColors(t *testing.T) {
} }
} }
// TestEventRangeText pins the human-readable "When" strings shown on the
// event detail view so any change to date formatting is intentional.
func TestEventRangeText(t *testing.T) {
cases := []struct {
name string
form EventFormData
want string
}{
{
name: "single allday date",
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-05"},
want: "August 5, 2026",
},
{
name: "all-day multi-day same month",
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-07"},
want: "Aug 5 7, 2026",
},
{
name: "all-day multi-day different months",
form: EventFormData{AllDay: true, StartDate: "2026-08-30", EndDate: "2026-09-02"},
want: "Aug 30, 2026 Sep 2, 2026",
},
{
name: "timed same day",
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "14:00", EndDate: "2026-08-05", EndTime: "15:00"},
want: "August 5, 2026, 14:00 15:00",
},
{
name: "timed different days same month",
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "23:30", EndDate: "2026-08-06", EndTime: "01:00"},
want: "Aug 5, 23:30 01:00, 2026",
},
{
name: "timed different months",
form: EventFormData{AllDay: false, StartDate: "2026-08-31", StartTime: "10:00", EndDate: "2026-09-01", EndTime: "11:00"},
want: "Aug 31, 2026, 10:00 Sep 1, 2026, 11:00",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := eventRangeText(c.form); got != c.want {
t.Errorf("eventRangeText(%+v) = %q, want %q", c.form, got, c.want)
}
})
}
}
func TestEventTextColorFallsBackForInvalidInput(t *testing.T) { func TestEventTextColorFallsBackForInvalidInput(t *testing.T) {
if got := eventTextColor(""); got != colorOrDefault("") { if got := eventTextColor(""); got != colorOrDefault("") {
t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got) t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got)
+1 -1
View File
File diff suppressed because one or more lines are too long