DAVx5/CalDAV clients displayed a shared calendar's DAV:displayname as "alice~work" (the synthetic owner~name local identifier nidus uses internally to keep a shared calendar's URL unique per subscriber). That internal name is still needed for routing (it's still what appears in the calendar's URL path), but there's no reason to show it to the end user as the calendar's label. calendarMeta() now always sets caldav.Calendar.Name to the plain calendar name (e.g. "work"), while Path still uses the "owner~name" local name. The web UI already showed the plain name via a separate "shared by <owner>" entry, so it's unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
553 lines
19 KiB
Go
553 lines
19 KiB
Go
package caldav
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
ical "github.com/emersion/go-ical"
|
||
"github.com/emersion/go-webdav"
|
||
"github.com/emersion/go-webdav/caldav"
|
||
"github.com/yourusername/caldav-server/internal/auth"
|
||
"github.com/yourusername/caldav-server/internal/config"
|
||
"github.com/yourusername/caldav-server/internal/db"
|
||
"github.com/yourusername/caldav-server/internal/store"
|
||
)
|
||
|
||
// sharedNameSep separates the owner from the calendar name in the
|
||
// synthetic name used for calendars shared with another user, e.g.
|
||
// "alice~work" for alice's "work" calendar as seen by whoever it was
|
||
// shared with. It must not collide with characters allowed in real
|
||
// calendar names (validated wherever calendar names are taken as input).
|
||
const sharedNameSep = "~"
|
||
|
||
// Backend implements caldav.Backend using a filesystem store.
|
||
type Backend struct {
|
||
cfg *config.Config
|
||
store *store.Store
|
||
dbase *db.DB // may be nil if sharing is not configured
|
||
logger *slog.Logger
|
||
}
|
||
|
||
// 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}
|
||
}
|
||
|
||
// 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 &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
|
||
}
|
||
|
||
// -------- caldav.Backend interface --------
|
||
|
||
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||
if auth.FromContext(ctx) == nil {
|
||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
// Must resolve to a path served by this same handler (i.e. under /cal/)
|
||
// at exactly one path segment of depth, since go-webdav's caldav server
|
||
// classifies resources purely by path depth relative to Handler.Prefix:
|
||
// depth 1 = principal, depth 2 = home-set, depth 3 = calendar, depth 4 =
|
||
// calendar object. A /principals/<user>/ path would never be reached
|
||
// (nothing is mounted there) and would break discovery.
|
||
return calPrincipalPath(), nil
|
||
}
|
||
|
||
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||
if auth.FromContext(ctx) == nil {
|
||
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
return calHomePath(), nil
|
||
}
|
||
|
||
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||
p := auth.FromContext(ctx)
|
||
if p == nil {
|
||
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
|
||
names, err := b.dbase.ListCalendars(p.Username)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("listing calendars: %w", err)
|
||
}
|
||
|
||
var cals []caldav.Calendar
|
||
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, 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 _, cal := range names {
|
||
configured["cal-"+cal.Name] = true
|
||
}
|
||
for _, dir := range disk {
|
||
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
|
||
name := strings.TrimPrefix(dir, "cal-")
|
||
cals = append(cals, b.calendarMeta(p.Username, name, name))
|
||
}
|
||
}
|
||
|
||
// Include calendars other users have shared with this one.
|
||
if b.dbase != nil {
|
||
shares, err := b.dbase.CalendarsSharedWith(p.Username)
|
||
if err != nil {
|
||
b.logger.Warn("listing shared calendars", "error", err)
|
||
}
|
||
for _, sh := range shares {
|
||
if _, err := b.store.GetCollection(sh.Owner, "cal-"+sh.CalendarName); err != nil {
|
||
continue // owner's calendar no longer exists
|
||
}
|
||
localName := sharedCalendarName(sh.Owner, sh.CalendarName)
|
||
cals = append(cals, b.calendarMeta(sh.Owner, sh.CalendarName, localName))
|
||
}
|
||
}
|
||
|
||
return cals, nil
|
||
}
|
||
|
||
func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) {
|
||
requester, localName, err := b.parseCalPath(ctx, calPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil {
|
||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||
}
|
||
cal := b.calendarMeta(owner, realName, localName)
|
||
return &cal, nil
|
||
}
|
||
|
||
func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
data, err := b.store.GetObject(owner, "cal-"+realName, objID)
|
||
if err != nil {
|
||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||
}
|
||
|
||
return b.decodeObject(objPath, data)
|
||
}
|
||
|
||
func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||
requester, localName, err := b.parseCalPath(ctx, calPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ids, err := b.store.ListObjects(owner, "cal-"+realName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var objs []caldav.CalendarObject
|
||
for _, id := range ids {
|
||
data, err := b.store.GetObject(owner, "cal-"+realName, id)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
obj, err := b.decodeObject(calObjectPath(localName, id), data)
|
||
if err != nil {
|
||
b.logger.Warn("decoding calendar object", "id", id, "error", err)
|
||
continue
|
||
}
|
||
objs = append(objs, *obj)
|
||
}
|
||
return objs, nil
|
||
}
|
||
|
||
func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||
// List all and filter – sufficient for small collections.
|
||
all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return caldav.Filter(query, all)
|
||
}
|
||
|
||
func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
|
||
p := auth.FromContext(ctx)
|
||
if p == nil {
|
||
return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
// Derive collection name from the trailing path segment. Calendars are
|
||
// always created under the acting user's own namespace — sharing an
|
||
// existing calendar is done via ShareCalendar, not by creating one
|
||
// directly in someone else's name.
|
||
name := path.Base(strings.TrimSuffix(calendar.Path, "/"))
|
||
if err := b.dbase.CreateCalendar(p.Username, name); err != nil && err != db.ErrResourceExists {
|
||
return fmt.Errorf("registering calendar: %w", err)
|
||
}
|
||
return b.store.EnsureCollection(p.Username, "cal-"+name)
|
||
}
|
||
|
||
func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
|
||
requester, localName, err := b.parseCalPath(ctx, calPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound {
|
||
return fmt.Errorf("unregistering calendar: %w", err)
|
||
}
|
||
return b.store.DeleteCollection(owner, "cal-"+realName)
|
||
}
|
||
|
||
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var buf strings.Builder
|
||
enc := ical.NewEncoder(&buf)
|
||
if err := enc.Encode(calendar); err != nil {
|
||
return nil, fmt.Errorf("encoding calendar: %w", err)
|
||
}
|
||
|
||
data := []byte(buf.String())
|
||
if err := b.store.PutObject(owner, "cal-"+realName, objID, data); err != nil {
|
||
return nil, fmt.Errorf("storing calendar object: %w", err)
|
||
}
|
||
|
||
return b.decodeObject(objPath, data)
|
||
}
|
||
|
||
func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) error {
|
||
requester, localName, objID, err := b.parseObjPath(ctx, objPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil {
|
||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// -------- helpers --------
|
||
|
||
// sharedCalendarName builds the synthetic local name a shared calendar is
|
||
// exposed under to the user it was shared with.
|
||
func sharedCalendarName(owner, calName string) string {
|
||
return owner + sharedNameSep + calName
|
||
}
|
||
|
||
// resolveCalendar maps a local calendar name (as seen in a URL path by
|
||
// requester) to its real owner and on-disk name, checking permissions
|
||
// along the way. If localName contains sharedNameSep, it's treated as a
|
||
// reference to another user's calendar and looked up in the shares table;
|
||
// otherwise it's assumed to be one of requester's own calendars.
|
||
//
|
||
// If requireWrite is true, a share must grant PermWrite or this returns a
|
||
// 403 Forbidden error. Owners always have full access to their own
|
||
// calendars.
|
||
func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool) (owner, realName string, perm db.Permission, err error) {
|
||
if ownerName, calName, ok := strings.Cut(localName, sharedNameSep); ok {
|
||
if b.dbase == nil {
|
||
return "", "", "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("sharing not enabled"))
|
||
}
|
||
share, err := b.dbase.CalendarShareFor(ownerName, calName, requester)
|
||
if err != nil {
|
||
return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("calendar not shared with you"))
|
||
}
|
||
if requireWrite && share.Permission != db.PermWrite {
|
||
return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("read-only share"))
|
||
}
|
||
return ownerName, calName, share.Permission, nil
|
||
}
|
||
return requester, localName, db.PermWrite, nil
|
||
}
|
||
|
||
func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar {
|
||
desc := fmt.Sprintf("%s's %s calendar", owner, realName)
|
||
return caldav.Calendar{
|
||
Path: calHomePath() + localName + "/",
|
||
// The displayed name is always the plain calendar name (never the
|
||
// "owner~name" synthetic local name used internally to keep a
|
||
// shared calendar's URL unique) — clients like DAVx5 show this as
|
||
// the calendar's label, and "alice~work" looked confusing there.
|
||
Name: realName,
|
||
Description: desc,
|
||
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
|
||
MaxResourceSize: 10 * 1024 * 1024, // 10 MiB
|
||
}
|
||
}
|
||
|
||
func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) {
|
||
cal, err := ical.NewDecoder(strings.NewReader(string(data))).Decode()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decoding ical: %w", err)
|
||
}
|
||
|
||
etag := fmt.Sprintf(`"%x"`, hashBytes(data))
|
||
|
||
return &caldav.CalendarObject{
|
||
Path: objPath,
|
||
ModTime: time.Now(),
|
||
ContentLength: int64(len(data)),
|
||
ETag: etag,
|
||
Data: cal,
|
||
}, nil
|
||
}
|
||
|
||
func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calName string, err error) {
|
||
p := auth.FromContext(ctx)
|
||
if p == nil {
|
||
return "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
user = p.Username
|
||
parts := strings.Split(strings.Trim(calPath, "/"), "/")
|
||
// expected: cal/home/<calname>/
|
||
if len(parts) < 3 {
|
||
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path"))
|
||
}
|
||
calName = parts[2]
|
||
return user, calName, nil
|
||
}
|
||
|
||
func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calName, objID string, err error) {
|
||
p := auth.FromContext(ctx)
|
||
if p == nil {
|
||
return "", "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
|
||
}
|
||
user = p.Username
|
||
parts := strings.Split(strings.Trim(objPath, "/"), "/")
|
||
// expected: cal/home/<calname>/<objid>
|
||
if len(parts) < 4 {
|
||
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
|
||
}
|
||
calName = parts[2]
|
||
objID = path.Base(objPath)
|
||
return user, calName, objID, nil
|
||
}
|
||
|
||
// calPrincipalPath, calHomePath, and calObjectPath are the same for every
|
||
// user: authorization is resolved from the Basic Auth identity, not from
|
||
// the URL, so no username segment is needed in the path.
|
||
//
|
||
// The fixed "home" segment (in place of a username) is required, not
|
||
// cosmetic: go-webdav's caldav server classifies a request purely by how
|
||
// many path segments it has relative to the handler's mount point — 1
|
||
// segment is treated as the principal, 2 as the calendar-home-set, 3 as a
|
||
// calendar, 4 as a calendar object. Removing that segment entirely would
|
||
// make the home-set and calendar paths misclassified as principal/home-set
|
||
// respectively, breaking discovery (empty PROPFIND responses).
|
||
func calPrincipalPath() string {
|
||
return "/cal/"
|
||
}
|
||
|
||
func calHomePath() string {
|
||
return "/cal/home/"
|
||
}
|
||
|
||
func calObjectPath(calName, objID string) string {
|
||
return fmt.Sprintf("/cal/home/%s/%s", calName, objID)
|
||
}
|
||
|
||
func hashBytes(data []byte) uint64 {
|
||
var h uint64 = 14695981039346656037
|
||
for _, b := range data {
|
||
h ^= uint64(b)
|
||
h *= 1099511628211
|
||
}
|
||
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>`)
|
||
|
||
// propstatBlockRe matches a single <propstat>...</propstat> element
|
||
// within a response, non-greedily.
|
||
var propstatBlockRe = regexp.MustCompile(`(?s)<propstat[^>]*>.*?</propstat>`)
|
||
|
||
// okPropRe matches the first <prop xmlns="DAV:"> opening tag inside a
|
||
// 200 OK propstat block, used to find where to insert new property XML.
|
||
var okPropRe = regexp.MustCompile(`<prop xmlns="DAV:">`)
|
||
|
||
// injectCalendarColors scans a multistatus PROPFIND response body and, for
|
||
// each <response> whose href is a calendar collection with a color set,
|
||
// inserts a <calendar-color xmlns="http://apple.com/ns/ical/"> element
|
||
// into its first 200 OK <prop>. Since go-webdav's stock property map
|
||
// doesn't know this property, a client that explicitly asks for it (as
|
||
// DAVx5 does) gets back a 404 propstat for it — that bogus 404 entry is
|
||
// stripped first, since leaving both a 404 and our injected 200 for the
|
||
// same property name in one <response> is invalid multistatus and
|
||
// confuses clients (dav4jvm/DAVx5 was observed to keep showing no color
|
||
// when both were present).
|
||
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
|
||
}
|
||
|
||
// Drop any propstat block that only complains calendar-color is
|
||
// unknown (a 404/not-found propstat containing a bare, empty
|
||
// calendar-color element), from either namespace clients might
|
||
// have queried it in.
|
||
block = propstatBlockRe.ReplaceAllFunc(block, func(ps []byte) []byte {
|
||
if bytes.Contains(ps, []byte("calendar-color")) && !bytes.Contains(ps, []byte("200 OK")) {
|
||
return nil
|
||
}
|
||
return ps
|
||
})
|
||
|
||
idx := okPropRe.FindIndex(block)
|
||
if idx == nil {
|
||
return block
|
||
}
|
||
insertAt := idx[1]
|
||
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
|
||
}
|