diff --git a/Documentation/SOGoInstallationGuide.asciidoc b/Documentation/SOGoInstallationGuide.asciidoc index ce2a1b88e..2e7b0d5f4 100644 --- a/Documentation/SOGoInstallationGuide.asciidoc +++ b/Documentation/SOGoInstallationGuide.asciidoc @@ -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 `!`. 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. diff --git a/Main/SOGo+DAV.m b/Main/SOGo+DAV.m index 3dee17683..8a97bbb41 100644 --- a/Main/SOGo+DAV.m +++ b/Main/SOGo+DAV.m @@ -548,8 +548,11 @@ davCurrentUserPrincipal = nil; else { + /* loginAlias preserves the ! 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", diff --git a/Main/SOGo.m b/Main/SOGo.m index d7ae0358f..3c287cdc9 100644 --- a/Main/SOGo.m +++ b/Main/SOGo.m @@ -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 + ! 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) diff --git a/SoObjects/Contacts/SOGoContactFolders.m b/SoObjects/Contacts/SOGoContactFolders.m index 1cff4682f..203ec94b8 100644 --- a/SoObjects/Contacts/SOGoContactFolders.m +++ b/SoObjects/Contacts/SOGoContactFolders.m @@ -32,6 +32,7 @@ #import #import +#import #import #import #import @@ -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/!/ 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]; diff --git a/SoObjects/SOGo/SOGoDAVAuthenticator.m b/SoObjects/SOGo/SOGoDAVAuthenticator.m index 65f43107d..a16d8ffdd 100644 --- a/SoObjects/SOGo/SOGoDAVAuthenticator.m +++ b/SoObjects/SOGo/SOGoDAVAuthenticator.m @@ -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 ! 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 diff --git a/SoObjects/SOGo/SOGoMobileProvision.m b/SoObjects/SOGo/SOGoMobileProvision.m index ef4fbf3ec..c3be72033 100644 --- a/SoObjects/SOGo/SOGoMobileProvision.m +++ b/SoObjects/SOGo/SOGoMobileProvision.m @@ -22,6 +22,7 @@ #import #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 to !. 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", diff --git a/SoObjects/SOGo/SOGoObject.m b/SoObjects/SOGo/SOGoObject.m index eab457939..5ed26feb9 100644 --- a/SoObjects/SOGo/SOGoObject.m +++ b/SoObjects/SOGo/SOGoObject.m @@ -1257,7 +1257,10 @@ davCurrentUserPrincipal = nil; else { - s = [NSString stringWithFormat: @"/SOGo/dav/%@", login]; + /* loginAlias preserves the ! 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", diff --git a/SoObjects/SOGo/SOGoSystemDefaults.h b/SoObjects/SOGo/SOGoSystemDefaults.h index a7e29c82c..03b9e446f 100644 --- a/SoObjects/SOGo/SOGoSystemDefaults.h +++ b/SoObjects/SOGo/SOGoSystemDefaults.h @@ -62,6 +62,7 @@ static const NSString *kDisableSharingCalendar = @"Calendar"; - (BOOL) isCalendarDAVAccessEnabled; - (BOOL) isCalendarJitsiLinkEnabled; - (BOOL) isAddressBookDAVAccessEnabled; +- (BOOL) carddavSingleAddressBookProfile; - (BOOL) enableEMailAlarms; diff --git a/SoObjects/SOGo/SOGoSystemDefaults.m b/SoObjects/SOGo/SOGoSystemDefaults.m index abca1d906..f162b8e19 100644 --- a/SoObjects/SOGo/SOGoSystemDefaults.m +++ b/SoObjects/SOGo/SOGoSystemDefaults.m @@ -588,6 +588,11 @@ _injectConfigurationFromFile (NSMutableDictionary *defaultsDict, return [self boolForKey: @"SOGoAddressBookDAVAccessEnabled"]; } +- (BOOL) carddavSingleAddressBookProfile +{ + return [self boolForKey: @"SOGoCarddavSingleAddressBookProfile"]; +} + - (BOOL) enableEMailAlarms { return [self boolForKey: @"SOGoEnableEMailAlarms"]; diff --git a/SoObjects/SOGo/SOGoUser.h b/SoObjects/SOGo/SOGoUser.h index 927a48246..4c3fb54df 100644 --- a/SoObjects/SOGo/SOGoUser.h +++ b/SoObjects/SOGo/SOGoUser.h @@ -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 ! 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; diff --git a/SoObjects/SOGo/SOGoUser.m b/SoObjects/SOGo/SOGoUser.m index 14ad6b6cd..62afd7df2 100644 --- a/SoObjects/SOGo/SOGoUser.m +++ b/SoObjects/SOGo/SOGoUser.m @@ -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 ! resolves to the real 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); diff --git a/SoObjects/SOGo/SOGoUserFolder.h b/SoObjects/SOGo/SOGoUserFolder.h index 43a945242..8aa29a4df 100644 --- a/SoObjects/SOGo/SOGoUserFolder.h +++ b/SoObjects/SOGo/SOGoUserFolder.h @@ -44,6 +44,8 @@ @class SOGoContactFolders; @interface SOGoUserFolder : SOGoFolder +{ +} /* ownership */ diff --git a/SoObjects/SOGo/SOGoUserFolder.m b/SoObjects/SOGo/SOGoUserFolder.m index f953d1a9c..e2da17361 100644 --- a/SoObjects/SOGo/SOGoUserFolder.m +++ b/SoObjects/SOGo/SOGoUserFolder.m @@ -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); }