feat(carddav): fix issue where only personal contacts can be synced on macOS (#1981). When , append the addressbook name as a username suffix to force macOS to treat each book as a distinct account. Usernames must not contain . Install via the provisioning profile. Closes #6033.

This commit is contained in:
smizrahi
2026-05-11 14:21:35 +02:00
parent d16a1a9708
commit 761934af42
13 changed files with 136 additions and 9 deletions
@@ -471,6 +471,26 @@ example.
Defaults to `YES` when unset.
|S |SOGoCarddavSingleAddressBookProfile
|Enables a per-addressbook CardDAV provisioning profile for macOS Contacts.
macOS Contacts.app cannot sync more than one addressbook per CardDAV account
and silently picks one through its own discovery, ignoring the
_CardDAVPrincipalURL_ in the provisioning profile.
When this flag is set to `YES`, downloading a CardDAV mobile profile from
SOGo produces a URL and a username of the form `<user>!<book>`. The `!book`
suffix acts as a server-side alias that resolves to the real user but
restricts the exposed addressbook to the selected one. The user can then
install one profile per addressbook to sync them as separate accounts.
The flag must only be enabled when no login in your directory contains the
character `!`, otherwise legitimate users would be unable to authenticate
(the suffix is stripped before the password check). The legacy macOS
behaviour, which restricts macOS Contacts to the `personal` addressbook
regardless of which one is requested, is disabled when this flag is on.
Defaults to `NO` when unset.
|S |SOGoCalendarDAVAccessEnabled
|Parameter controlling WebDAV access to the Calendar collections.
+4 -1
View File
@@ -548,8 +548,11 @@
davCurrentUserPrincipal = nil;
else
{
/* loginAlias preserves the <user>!<book> alias used by the
per-addressbook CardDAV profile, so that macOS Contacts keeps
using the alias URL instead of falling back on the canonical one. */
usersUrl = [NSString stringWithFormat: @"%@%@/",
[self davURLAsString], login];
[self davURLAsString], [activeUser loginAlias]];
userHREF = davElementWithContent (@"href", XMLNS_WEBDAV, usersUrl);
davCurrentUserPrincipal
= [davElementWithContent (@"current-user-principal",
+4
View File
@@ -387,6 +387,10 @@ static BOOL debugLeaks;
//Here the user is expected to be name or name@domain.com
//However iOS 18.4 sanitize the caldav url and put %40 instead of @
login = [login stringByReplacingOccurrencesOfString: @"%40" withString: @"@"];
/* When SOGoCarddavSingleAddressBookProfile is on, login may carry a
<user>!<book> alias suffix; userWithLogin: strips it internally, but we
keep the alias as the folder name so generated hrefs preserve the !book
discriminator. */
user = [SOGoUser userWithLogin: login roles: nil];
}
if (user)
+21 -1
View File
@@ -32,6 +32,7 @@
#import <SOGo/NSObject+DAV.h>
#import <SOGo/SOGoUser.h>
#import <SOGo/SOGoUserFolder.h>
#import <SOGo/SOGoUserManager.h>
#import <SOGo/SOGoSystemDefaults.h>
#import <SOGo/WORequest+SOGo.h>
@@ -299,8 +300,27 @@ Class SOGoContactSourceFolderK;
- (NSArray *) toManyRelationshipKeys
{
NSMutableArray *keys;
SOGoSystemDefaults *sd;
if ([[context request] isMacOSXAddressBookApp])
sd = [SOGoSystemDefaults sharedSystemDefaults];
/* Per-addressbook CardDAV profile: when enabled and the parent user folder
is reached through a /dav/<user>!<book>/ alias URL, expose only that
book. The legacy macOS "personal only" shortcut below is also gated on
the flag so the two mechanisms don't overlap. */
if ([sd carddavSingleAddressBookProfile])
{
NSString *parentName;
NSRange separator;
parentName = [[self lookupUserFolder] nameInContainer];
separator = [parentName rangeOfString: @"!"];
if (separator.location != NSNotFound)
return [NSArray arrayWithObject:
[parentName substringFromIndex: separator.location + 1]];
}
if ([[context request] isMacOSXAddressBookApp]
&& ![sd carddavSingleAddressBookProfile])
keys = [NSMutableArray arrayWithObject: @"personal"];
else
keys = (NSMutableArray *) [super toManyRelationshipKeys];
+15 -3
View File
@@ -49,7 +49,7 @@
- (BOOL) checkLogin: (NSString *) _login
password: (NSString *) _pwd
{
NSString *domain;
NSString *domain, *checkedLogin;
SOGoSystemDefaults *sd;
SOGoCASSession *session;
SOGoPasswordPolicyError perr;
@@ -58,9 +58,21 @@
domain = nil;
perr = PolicyNoError;
sd = [SOGoSystemDefaults sharedSystemDefaults];
checkedLogin = [_login stringByReplacingString: @"%40" withString: @"@"];
/* Per-addressbook CardDAV profile: a login of the form <user>!<book> is an
alias used to discriminate the URL path; the password check applies to
the underlying real user. */
if ([sd carddavSingleAddressBookProfile])
{
NSRange aliasRange = [checkedLogin rangeOfString: @"!"];
if (aliasRange.location != NSNotFound)
checkedLogin = [checkedLogin substringToIndex: aliasRange.location];
}
rc = ([[SOGoUserManager sharedUserManager]
checkLogin: [_login stringByReplacingString: @"%40"
withString: @"@"]
checkLogin: checkedLogin
password: _pwd
domain: &domain
perr: &perr
+30 -2
View File
@@ -22,6 +22,7 @@
#import <Foundation/Foundation.h>
#import "SOGoMobileProvision.h"
#import "SOGoSystemDefaults.h"
#import "SOGoUser.h"
#import "NSString+Crypto.h"
@@ -34,11 +35,12 @@
NSURL *serverURL;
NSDictionary *provisioning;
NSError *error;
NSString *payloadType, *prefix, *type;
NSString *payloadType, *prefix, *type, *username;
NSNumber *port;
activeUser = [context activeUser];
serverURL = [context serverURL];
username = [activeUser login];
if (!context || !path) {
[self errorWithFormat: @"Invalid provisioning profile - no parameters"];
@@ -55,6 +57,32 @@
payloadType = @"com.apple.carddav.account";
prefix = @"CardDAV";
type = @"Contact";
/* Per-addressbook CardDAV profile: rewrite the principal URL and the
CardDAV username from <user> to <user>!<book>. macOS Contacts
strips path-only discriminators when it falls back on its own
discovery; embedding the alias in the username keeps the !book
tag in every subsequent request, both in the URL and in the Basic
Auth header. */
if ([[SOGoSystemDefaults sharedSystemDefaults] carddavSingleAddressBookProfile])
{
NSArray *parts;
parts = [path componentsSeparatedByString: @"/"];
if ([parts count] >= 7
&& [[parts objectAtIndex: 4] isEqualToString: @"Contacts"])
{
NSString *aliasLogin;
NSMutableArray *rewritten;
aliasLogin = [NSString stringWithFormat: @"%@!%@",
[parts objectAtIndex: 3],
[parts objectAtIndex: 5]];
rewritten = [parts mutableCopy];
[rewritten replaceObjectAtIndex: 3 withObject: aliasLogin];
path = [rewritten componentsJoinedByString: @"/"];
[rewritten release];
username = aliasLogin;
}
}
break;
}
@@ -74,7 +102,7 @@
port, [NSString stringWithFormat:@"%@%@", prefix, @"Port"],
path, [NSString stringWithFormat:@"%@%@", prefix, @"PrincipalURL"],
[NSNumber numberWithBool:[[serverURL scheme] isEqualToString:@"https"]], [NSString stringWithFormat:@"%@%@", prefix, @"UseSSL"],
[activeUser login], [NSString stringWithFormat:@"%@%@", prefix, @"Username"],
username, [NSString stringWithFormat:@"%@%@", prefix, @"Username"],
[NSString stringWithFormat: @"SOGo %@ provisioning", prefix], @"PayloadDescription",
[NSString stringWithFormat:@"%@ %@", type, name], @"PayloadDisplayName",
[NSString stringWithFormat:@"%@.%@.apple.%@", [serverURL host], [name asMD5String], [prefix lowercaseString]], @"PayloadIdentifier",
+4 -1
View File
@@ -1257,7 +1257,10 @@
davCurrentUserPrincipal = nil;
else
{
s = [NSString stringWithFormat: @"/SOGo/dav/%@", login];
/* loginAlias preserves the <user>!<book> alias used by the
per-addressbook CardDAV profile, so that macOS Contacts keeps
using the alias URL instead of falling back on the canonical one. */
s = [NSString stringWithFormat: @"/SOGo/dav/%@", [activeUser loginAlias]];
userHREF = davElementWithContent (@"href", XMLNS_WEBDAV, s);
davCurrentUserPrincipal
= [davElementWithContent (@"current-user-principal",
+1
View File
@@ -62,6 +62,7 @@ static const NSString *kDisableSharingCalendar = @"Calendar";
- (BOOL) isCalendarDAVAccessEnabled;
- (BOOL) isCalendarJitsiLinkEnabled;
- (BOOL) isAddressBookDAVAccessEnabled;
- (BOOL) carddavSingleAddressBookProfile;
- (BOOL) enableEMailAlarms;
+5
View File
@@ -588,6 +588,11 @@ _injectConfigurationFromFile (NSMutableDictionary *defaultsDict,
return [self boolForKey: @"SOGoAddressBookDAVAccessEnabled"];
}
- (BOOL) carddavSingleAddressBookProfile
{
return [self boolForKey: @"SOGoCarddavSingleAddressBookProfile"];
}
- (BOOL) enableEMailAlarms
{
return [self boolForKey: @"SOGoEnableEMailAlarms"];
+5
View File
@@ -63,6 +63,10 @@
SOGoUserFolder *homeFolder;
NSString *currentPassword;
NSString *loginInDomain;
/* The login as it appeared at lookup time, before any alias stripping
(per-addressbook CardDAV profile uses <user>!<book> aliases). Equal
to [self login] for regular users. */
NSString *loginAlias;
//NSString *language;
NSArray *allEmails;
NSMutableArray *mailAccounts;
@@ -88,6 +92,7 @@
- (NSString *) currentPassword;
- (NSString *) loginInDomain;
- (NSString *) loginAlias;
/* properties */
- (NSString *) domain;
+21
View File
@@ -144,11 +144,26 @@
realUID = nil;
domain = nil;
ASSIGN (loginAlias, newLogin);
if ([newLogin isEqualToString: @"anonymous"] || [newLogin isEqualToString: @"freebusy"])
realUID = newLogin;
else
{
sd = [SOGoSystemDefaults sharedSystemDefaults];
/* Per-addressbook CardDAV profile: strip the alias suffix so that a login
of the form <user>!<book> resolves to the real user <user>. The book
part is consumed elsewhere (Main/SOGo.m, SOGoContactFolders) to filter
the addressbook listing. The original alias is kept in loginAlias so
that DAV property hrefs (current-user-principal, etc.) preserve it. */
if ([sd carddavSingleAddressBookProfile])
{
NSRange aliasRange = [newLogin rangeOfString: @"!"];
if (aliasRange.location != NSNotFound)
newLogin = [newLogin substringToIndex: aliasRange.location];
}
if ([sd enableDomainBasedUID] || [[sd loginDomains] count] > 0)
{
r = [newLogin rangeOfString: @"@" options: NSBackwardsSearch];
@@ -243,9 +258,15 @@
[currentPassword release];
[cn release];
[loginInDomain release];
[loginAlias release];
[super dealloc];
}
- (NSString *) loginAlias
{
return [loginAlias length] ? loginAlias : self->login;
}
- (void) setPrimaryRoles: (NSArray *) newRoles
{
ASSIGN (roles, newRoles);
+2
View File
@@ -44,6 +44,8 @@
@class SOGoContactFolders;
@interface SOGoUserFolder : SOGoFolder
{
}
/* ownership */
+4 -1
View File
@@ -102,11 +102,14 @@
ownerUser = [SOGoUser userWithLogin: nameInContainer roles: nil];
login = [ownerUser login];
[self setOwner: login];
if (![nameInContainer isEqualToString: login])
if (![nameInContainer isEqualToString: login]
&& [nameInContainer rangeOfString: @"!"].location == NSNotFound)
// In case the user domain is specified in the URL but not in the user
// login name, we remove it (user@domain => user).
// This happens when SOGoLoginDomains is defined but
// SOGoEnableDomainBasedUID is disabled.
// The !book alias used by the per-addressbook CardDAV profile must be
// preserved here so generated hrefs keep the discriminator.
ASSIGN(nameInContainer, login);
}