feat(web): show ICS subscription events in detail view

This commit is contained in:
2026-09-02 20:02:48 +02:00
parent e18c83b618
commit f34041d889
11 changed files with 908 additions and 80 deletions
+51 -10
View File
@@ -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 "!<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) {
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 "!<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
@@ -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: "#",
})
}
}
+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]
}
+10 -3
View File
@@ -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/"
+1 -1
View File
@@ -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