Disambiguate shared calendars with a name colliding with the owner's

If a calendar shared with a user has the same name as one of the user's
own calendars (or as another share from a different owner), DAVx5 would
show two calendars with an identical, indistinguishable title. The
shared calendar's CalDAV display name (Calendar.Name, exposed via
displayname/DAVx5's calendar list) now gets the owning user's username
appended in parentheses in that case, e.g. "personal (alice)" — the
underlying calendar path/URL is unaffected, and a user's own calendars
are never renamed (only shared ones can collide with something else).

internal/caldav/backend.go: calendarMeta split into calendarMeta (own
calendars, unchanged name) and calendarMetaNamed (explicit display name);
new sharedDisplayName(requester, owner, realName) checks requester's own
calendars and other shares for a collision. Wired into both
ListCalendars and GetCalendar (the latter used by direct
PROPFIND/REPORT against a shared calendar's own URL).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 23:20:17 +02:00
co-authored by Copilot
parent 213b22f821
commit b58a6ba4ea
2 changed files with 103 additions and 3 deletions
+49 -3
View File
@@ -136,7 +136,8 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
continue // owner's calendar no longer exists continue // owner's calendar no longer exists
} }
localName := sharedCalendarName(sh.Owner, sh.CalendarName) localName := sharedCalendarName(sh.Owner, sh.CalendarName)
cals = append(cals, b.calendarMeta(sh.Owner, sh.CalendarName, localName)) displayName := b.sharedDisplayName(p.Username, sh.Owner, sh.CalendarName)
cals = append(cals, b.calendarMetaNamed(sh.Owner, sh.CalendarName, localName, displayName))
} }
} }
@@ -163,7 +164,11 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale
if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil { if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err) return nil, webdav.NewHTTPError(http.StatusNotFound, err)
} }
cal := b.calendarMeta(owner, realName, localName) displayName := realName
if owner != requester {
displayName = b.sharedDisplayName(requester, owner, realName)
}
cal := b.calendarMetaNamed(owner, realName, localName, displayName)
return &cal, nil return &cal, nil
} }
@@ -369,6 +374,14 @@ func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool
} }
func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar { func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar {
return b.calendarMetaNamed(owner, realName, localName, realName)
}
// calendarMetaNamed is calendarMeta with an explicit displayName, used for
// a calendar shared with the requester whose name collides with one of
// their own calendars (see sharedDisplayName) — the Description still
// always names the real calendar, only the client-visible Name changes.
func (b *Backend) calendarMetaNamed(owner, realName, localName, displayName string) caldav.Calendar {
desc := fmt.Sprintf("%s's %s calendar", owner, realName) desc := fmt.Sprintf("%s's %s calendar", owner, realName)
return caldav.Calendar{ return caldav.Calendar{
Path: calHomePath() + localName + "/", Path: calHomePath() + localName + "/",
@@ -376,13 +389,46 @@ func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calenda
// "owner~name" synthetic local name used internally to keep a // "owner~name" synthetic local name used internally to keep a
// shared calendar's URL unique) — clients like DAVx5 show this as // shared calendar's URL unique) — clients like DAVx5 show this as
// the calendar's label, and "alice~work" looked confusing there. // the calendar's label, and "alice~work" looked confusing there.
Name: realName, Name: displayName,
Description: desc, Description: desc,
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"}, SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
MaxResourceSize: 10 * 1024 * 1024, // 10 MiB MaxResourceSize: 10 * 1024 * 1024, // 10 MiB
} }
} }
// sharedDisplayName returns realName, suffixed with the owning user's
// name in parentheses (e.g. "Work (bob)") if requester has another
// calendar with the exact same display name — either one of their own,
// or another share from a different owner — so DAVx5 (and other clients)
// don't show two calendars with an identical, indistinguishable title.
// Only called for calendars shared with requester; requester's own
// calendars never need disambiguating against themselves.
func (b *Backend) sharedDisplayName(requester, owner, realName string) string {
own, err := b.dbase.ListCalendars(requester)
if err != nil {
b.logger.Warn("listing own calendars for disambiguation", "error", err)
}
for _, c := range own {
if c.Name == realName {
return fmt.Sprintf("%s (%s)", realName, owner)
}
}
shares, err := b.dbase.CalendarsSharedWith(requester)
if err != nil {
b.logger.Warn("listing shared calendars for disambiguation", "error", err)
}
for _, sh := range shares {
if sh.Owner == owner && sh.CalendarName == realName {
continue // the calendar itself, not a collision
}
if sh.CalendarName == realName {
return fmt.Sprintf("%s (%s)", realName, owner)
}
}
return realName
}
func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) { func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) {
// Some clients (Outlook/Exchange, some Thunderbird/Lightning setups) // Some clients (Outlook/Exchange, some Thunderbird/Lightning setups)
// write Windows timezone names into TZID instead of IANA ones, which // write Windows timezone names into TZID instead of IANA ones, which
+54
View File
@@ -277,3 +277,57 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
t.Fatalf("expected a single 200 OK propstat with no 404, got: %s", body) t.Fatalf("expected a single 200 OK propstat with no 404, got: %s", body)
} }
} }
// TestSharedCalendarNameCollidingWithOwnGetsDisambiguated covers the case
// where a calendar shared with a user has the same name as one of that
// user's own calendars: DAVx5 (and any other client) would otherwise show
// two calendars with an identical, indistinguishable title, so the shared
// one's display name gets the owner's username appended in parentheses.
func TestSharedCalendarNameCollidingWithOwnGetsDisambiguated(t *testing.T) {
b, dbase := newTestBackend(t)
// alice creates her own "personal" calendar, colliding with bob's
// pre-existing "personal" calendar (see newTestBackend), then shares
// it with bob.
if err := dbase.CreateCalendar("alice", "personal"); err != nil {
t.Fatalf("CreateCalendar alice/personal: %v", err)
}
if err := dbase.ShareCalendar("alice", "personal", "bob", db.PermRead); err != nil {
t.Fatalf("ShareCalendar: %v", err)
}
if _, err := b.ListCalendars(ctxFor("alice")); err != nil {
t.Fatalf("ListCalendars(alice): %v", err)
}
cals, err := b.ListCalendars(ctxFor("bob"))
if err != nil {
t.Fatalf("ListCalendars(bob): %v", err)
}
wantPath := calHomePath() + sharedCalendarName("alice", "personal") + "/"
var sharedName, ownName string
for _, c := range cals {
if c.Path == wantPath {
sharedName = c.Name
}
if c.Path == calHomePath()+"personal/" {
ownName = c.Name
}
}
if ownName != "personal" {
t.Fatalf("expected bob's own calendar to keep its plain name, got %q", ownName)
}
if want := "personal (alice)"; sharedName != want {
t.Fatalf("expected shared calendar display name %q, got %q", want, sharedName)
}
// GetCalendar (single-collection lookup, as used by PROPFIND on the
// calendar's own URL) must disambiguate the same way.
cal, err := b.GetCalendar(ctxFor("bob"), wantPath)
if err != nil {
t.Fatalf("GetCalendar: %v", err)
}
if want := "personal (alice)"; cal.Name != want {
t.Fatalf("GetCalendar: expected display name %q, got %q", want, cal.Name)
}
}