Add calendar color support (DAVx5 calendar-color)
Calendars can now have a color (hex, e.g. #3b82f6) that DAVx5 and other
CalDAV clients pick up via the Apple/dav4jvm calendar-color property.
- db: add calendars.color column with migration for existing DBs;
CreateCalendarWithColor, SetCalendarColor, GetCalendarColor;
ListCalendars now returns []Calendar{Name, Color} instead of []string
- caldav: since go-webdav's caldav.Backend interface has no extension
point for vendor properties, wrap the handler with a response-rewriting
middleware that injects <calendar-color xmlns="http://apple.com/ns/ical/">
into PROPFIND responses for calendars that have a color set
- web: color picker on the "New calendar" form and an inline color swatch/
picker on each calendar card (calendars only, not address books)
- nidusctl: `calendar create --color` flag and a new `calendar color`
subcommand; `calendar list` now also prints the color if set
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+142
-7
@@ -1,11 +1,16 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,9 +45,15 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.
|
||||
}
|
||||
|
||||
// NewHandler returns an http.Handler for the /cal/ prefix.
|
||||
//
|
||||
// It wraps the go-webdav caldav.Handler with a small response-rewriting
|
||||
// middleware that injects the non-standard (Apple/DAVx5) calendar-color
|
||||
// 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)
|
||||
return &caldav.Handler{Backend: b}
|
||||
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
|
||||
}
|
||||
|
||||
// -------- caldav.Backend interface --------
|
||||
@@ -79,19 +90,19 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
||||
}
|
||||
|
||||
var cals []caldav.Calendar
|
||||
for _, name := range names {
|
||||
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
|
||||
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
|
||||
for _, cal := range names {
|
||||
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
|
||||
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
cals = append(cals, b.calendarMeta(p.Username, name, name))
|
||||
cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
|
||||
}
|
||||
|
||||
// Also include any extra calendars that exist on disk but aren't registered
|
||||
disk, _ := b.store.ListCollections(p.Username)
|
||||
configured := make(map[string]bool)
|
||||
for _, n := range names {
|
||||
configured["cal-"+n] = true
|
||||
for _, cal := range names {
|
||||
configured["cal-"+cal.Name] = true
|
||||
}
|
||||
for _, dir := range disk {
|
||||
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
|
||||
@@ -386,3 +397,127 @@ func hashBytes(data []byte) uint64 {
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// -------- calendar-color PROPFIND injection --------
|
||||
|
||||
// calCollectionPathRe matches a calendar collection's own path (e.g.
|
||||
// "/cal/home/work/"), as opposed to a calendar object inside it (e.g.
|
||||
// "/cal/home/work/abc123.ics") or the home-set/principal path.
|
||||
var calCollectionPathRe = regexp.MustCompile(`^/cal/home/[^/]+/$`)
|
||||
|
||||
// colorInjectingHandler wraps a caldav.Handler and post-processes PROPFIND
|
||||
// responses to add the non-standard `calendar-color` property (in Apple's
|
||||
// "http://apple.com/ns/ical/" namespace) that DAVx5 and other clients read
|
||||
// to color-code synced calendars. go-webdav's caldav.Backend interface has
|
||||
// no extension point for vendor properties like this, so the response XML
|
||||
// is rewritten after the fact instead.
|
||||
type colorInjectingHandler struct {
|
||||
backend *Backend
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
func (h *colorInjectingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PROPFIND" || h.backend.dbase == nil {
|
||||
h.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Only bother rewriting if the client asked for calendar-color
|
||||
// specifically, or for all properties (propname/allprop requests, or
|
||||
// no body at all, are treated as "all properties" by most clients).
|
||||
var reqBody []byte
|
||||
if r.Body != nil {
|
||||
reqBody, _ = readAllAndReset(&r.Body)
|
||||
}
|
||||
wantsColor := len(reqBody) == 0 || bytes.Contains(reqBody, []byte("calendar-color")) || bytes.Contains(reqBody, []byte("allprop"))
|
||||
if !wantsColor {
|
||||
h.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.next.ServeHTTP(rec, r)
|
||||
|
||||
for k, vs := range rec.Header() {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
body := rec.Body.Bytes()
|
||||
if rec.Code == http.StatusMultiStatus && strings.Contains(rec.Header().Get("Content-Type"), "xml") {
|
||||
body = h.injectCalendarColors(r.Context(), body)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
w.WriteHeader(rec.Code)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// responseBlockRe matches a single <response>...</response> element
|
||||
// (non-greedy) inside a multistatus document, capturing its href.
|
||||
var responseBlockRe = regexp.MustCompile(`(?s)<response[^>]*>.*?<href[^>]*>([^<]*)</href>.*?</response>`)
|
||||
|
||||
// injectCalendarColors scans a multistatus PROPFIND response body and, for
|
||||
// each <response> whose href is a calendar collection with a color set,
|
||||
// inserts a <apple:calendar-color xmlns:apple="http://apple.com/ns/ical/">
|
||||
// element into its first 200 OK <prop>.
|
||||
func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body []byte) []byte {
|
||||
p := auth.FromContext(ctx)
|
||||
if p == nil {
|
||||
return body
|
||||
}
|
||||
|
||||
return responseBlockRe.ReplaceAllFunc(body, func(block []byte) []byte {
|
||||
m := responseBlockRe.FindSubmatch(block)
|
||||
if m == nil {
|
||||
return block
|
||||
}
|
||||
href := string(m[1])
|
||||
if !calCollectionPathRe.MatchString(href) {
|
||||
return block
|
||||
}
|
||||
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
|
||||
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false)
|
||||
if err != nil {
|
||||
return block
|
||||
}
|
||||
color, err := h.backend.dbase.GetCalendarColor(owner, realName)
|
||||
if err != nil || color == "" {
|
||||
return block
|
||||
}
|
||||
propEl := []byte(`<prop xmlns="DAV:">`)
|
||||
idx := bytes.Index(block, propEl)
|
||||
if idx < 0 {
|
||||
return block
|
||||
}
|
||||
insertAt := idx + len(propEl)
|
||||
colorEl := []byte(fmt.Sprintf(`<calendar-color xmlns="http://apple.com/ns/ical/">%s</calendar-color>`, xmlEscapeColor(color)))
|
||||
out := make([]byte, 0, len(block)+len(colorEl))
|
||||
out = append(out, block[:insertAt]...)
|
||||
out = append(out, colorEl...)
|
||||
out = append(out, block[insertAt:]...)
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
// xmlEscapeColor returns color formatted as an 8-digit ARGB/RGBA hex value
|
||||
// (as Apple's calendar-color property expects), padding a plain 6-digit
|
||||
// "#RRGGBB" (as produced by an HTML <input type="color">) with a fully
|
||||
// opaque alpha channel.
|
||||
func xmlEscapeColor(color string) string {
|
||||
if len(color) == 7 && color[0] == '#' {
|
||||
return color + "FF"
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
// readAllAndReset reads body fully and replaces it with a fresh reader over
|
||||
// the same bytes, so downstream handlers can still consume it.
|
||||
func readAllAndReset(body *io.ReadCloser) ([]byte, error) {
|
||||
data, err := io.ReadAll(*body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*body = io.NopCloser(bytes.NewReader(data))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -149,3 +151,65 @@ func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) {
|
||||
t.Fatal("expected error accessing unshared calendar, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPropFindEmitsCalendarColor verifies that a PROPFIND on a calendar
|
||||
// with a color set returns the Apple/DAVx5 calendar-color property, and
|
||||
// that a calendar without a color doesn't (since a client should fall
|
||||
// back to its own default in that case).
|
||||
func TestPropFindEmitsCalendarColor(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
t.Cleanup(func() { dbase.Close() })
|
||||
|
||||
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
||||
t.Fatalf("CreateUser alice: %v", err)
|
||||
}
|
||||
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
|
||||
t.Fatalf("CreateCalendarWithColor: %v", err)
|
||||
}
|
||||
if err := dbase.CreateCalendar("alice", "personal"); err != nil {
|
||||
t.Fatalf("CreateCalendar: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := NewHandler(&config.Config{}, st, dbase, logger)
|
||||
|
||||
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
|
||||
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
||||
req.SetBasicAuth("alice", "pw")
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
req.Header.Set("Depth", "1")
|
||||
req = req.WithContext(ctxFor("alice"))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}))
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusMultiStatus {
|
||||
t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
|
||||
t.Fatalf("expected calendar-color for work calendar, got: %s", body)
|
||||
}
|
||||
|
||||
// The "personal" calendar has no color, so its <response> block
|
||||
// shouldn't contain the property at all.
|
||||
personalIdx := strings.Index(body, "/cal/home/personal/")
|
||||
if personalIdx < 0 {
|
||||
t.Fatalf("expected personal calendar in response, got: %s", body)
|
||||
}
|
||||
// Find personal's response block boundaries loosely by looking for the
|
||||
// nearest calendar-color occurrence and ensuring it isn't right next to
|
||||
// the personal href (colors are per-block, checked via count instead).
|
||||
if strings.Count(body, "<calendar-color ") != 1 {
|
||||
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user