diff --git a/README.md b/README.md index a81343a34af04d66507f9243da5a84ce13c3a386..1378930d799e1d5b2c76598cb946d18810823b93 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,34 @@ occ ecloud-accounts:fix-missing-emails --users=user1 --users=user2 --dry-run 4. Sets only the email address in NextCloud (quota remains unchanged) 5. Provides detailed output of the process and any errors encountered +## Sync Missing Users to Common Database + +- This app provides a command to sync users that exist in NextCloud but are missing from the common database +- The command fetches user metadata from LDAP and adds missing users to the common database + +### Usage + +```bash +# Sync all missing users (dry run first) +occ ecloud-accounts:sync-missing-users-to-common --dry-run + +# Sync all missing users +occ ecloud-accounts:sync-missing-users-to-common + +# Sync specific users (multiple --users arguments) +occ ecloud-accounts:sync-missing-users-to-common --users=user1 --users=user2 --users=user3 + +# Sync specific users with dry run and custom IP +occ ecloud-accounts:sync-missing-users-to-common --users=user1 --users=user2 --dry-run --ip-address=192.168.1.100 +``` + +### What it does + +1. Queries NextCloud database to find all users +2. Checks which users are missing from the common database +3. For each missing user, fetches metadata from LDAP (displayName, recoveryMailAddress, etc.) +4. Adds the user to the common database with the fetched metadata + ## Support Please open issues here : https://gitlab.e.foundation/e/backlog/issues diff --git a/appinfo/info.xml b/appinfo/info.xml index 4c4d4c41d0b2db0b6b33b04cf829367ecefda7f5..e2145280e36f317ab0c527d5c487300995054460 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ - 10.0.6 + 10.1.0 agpl Murena SAS EcloudAccounts @@ -29,5 +29,6 @@ OCA\EcloudAccounts\Command\MigrateWebmailAddressbooks OCA\EcloudAccounts\Command\MapActiveAttributetoLDAP OCA\EcloudAccounts\Command\FixMissingEmails + OCA\EcloudAccounts\Command\SyncMissingUsersToCommon diff --git a/lib/Command/SyncMissingUsersToCommon.php b/lib/Command/SyncMissingUsersToCommon.php new file mode 100644 index 0000000000000000000000000000000000000000..5df790f519c6e594087dcf2fc462e9d51795b19f --- /dev/null +++ b/lib/Command/SyncMissingUsersToCommon.php @@ -0,0 +1,229 @@ +ldapConnectionService = $ldapConnectionService; + $this->userService = $userService; + $this->userManager = $userManager; + } + + protected function configure(): void { + $this + ->setName(Application::APP_ID . ':sync-missing-users-to-common') + ->setDescription('Syncs missing users from NextCloud to common database by fetching metadata from LDAP') + ->addOption( + 'users', + null, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'Users to sync (can be specified multiple times, e.g., --users=user1 --users=user2)', + [] + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Show what would be done without making changes' + ) + ->addOption( + 'ip-address', + null, + InputOption::VALUE_OPTIONAL, + 'IP address to use for all users (store blank if not set)', + null + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $isDryRun = $input->getOption('dry-run'); + $usernames = $input->getOption('users'); + $ipAddress = $this->normalizeIpAddress($input->getOption('ip-address')); + + if ($isDryRun) { + $output->writeln('DRY RUN MODE - No changes will be made'); + } + + $stats = $this->processUsers($usernames, $ipAddress, $isDryRun, $output); + $this->displayFinalResults($stats, $output); + + return 0; + } catch (Exception $e) { + $output->writeln(sprintf('Error: %s', $e->getMessage())); + return 1; + } + } + + /** + * Normalize IP address input - convert null to empty string + */ + private function normalizeIpAddress(?string $ipAddress): string { + return $ipAddress ?? ''; + } + + /** + * Process users based on whether specific users are provided or all users + */ + private function processUsers(array $usernames, string $ipAddress, bool $isDryRun, OutputInterface $output): array { + $stats = [ + 'processed' => 0, + 'success' => 0, + 'errors' => 0 + ]; + + if (!empty($usernames)) { + $this->processSpecificUsers($usernames, $ipAddress, $isDryRun, $output, $stats); + } else { + $this->processAllUsers($ipAddress, $isDryRun, $output, $stats); + } + + return $stats; + } + + /** + * Process a list of specific users + */ + private function processSpecificUsers(array $usernames, string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void { + $output->writeln(sprintf('Processing %d specific users', count($usernames))); + + foreach ($usernames as $username) { + $this->processSingleUser($username, $ipAddress, $isDryRun, $output, $stats); + } + } + + /** + * Process all users from NextCloud + */ + private function processAllUsers(string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void { + $allUsers = []; + $this->userManager->callForAllUsers(function (IUser $user) use (&$allUsers) { + $allUsers[] = $user; + }); + $totalUsers = count($allUsers); + $output->writeln(sprintf('Processing all users from NextCloud (total: %d)', $totalUsers)); + + $lastProcessed = 0; + foreach ($allUsers as $user) { + $before = $stats['processed']; + $this->processSingleUser($user->getUID(), $ipAddress, $isDryRun, $output, $stats); + if ($stats['processed'] > 0 && $stats['processed'] % 100 === 0 && $stats['processed'] !== $lastProcessed) { + $output->writeln(sprintf('Progress: %d processed, %d success, %d errors', + $stats['processed'], $stats['success'], $stats['errors'])); + $lastProcessed = $stats['processed']; + } + } + } + + /** + * Process a single user + */ + private function processSingleUser(string $username, string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void { + $user = $this->userManager->get($username); + if (!$user) { + $output->writeln(sprintf('User not found: %s', $username)); + $stats['errors']++; + return; + } + + // Strip legacy domain from username before checking in common DB + $usernameWithoutDomain = $this->userService->stripLegacyDomainFromUsername($username); + if ($this->userService->isUsernameTaken($usernameWithoutDomain)) { + return; // Skip if already exists + } + + try { + $this->syncUserToCommon($username, $ipAddress, $isDryRun, $output); + $stats['success']++; + } catch (Exception $e) { + $output->writeln(sprintf('Error syncing user %s: %s', $username, $e->getMessage())); + $stats['errors']++; + } + $stats['processed']++; + } + + /** + * Display final results + */ + private function displayFinalResults(array $stats, OutputInterface $output): void { + $output->writeln(sprintf('Final result: %d users processed, %d success, %d errors.', + $stats['processed'], $stats['success'], $stats['errors'])); + } + + /** + * Sync a specific user to the common database + * + * @param string $username The username to sync + * @param string $ipAddress The IP address to use + * @param bool $isDryRun Whether this is a dry run + * @param OutputInterface $output The output interface for writing messages + * @return void + */ + private function syncUserToCommon(string $username, string $ipAddress, bool $isDryRun, OutputInterface $output): void { + $output->writeln(sprintf('Processing user: %s', $username)); + + // Check if user exists and is on LDAP backend + $user = $this->userManager->get($username); + if (!$user) { + throw new Exception("User not found in NextCloud"); + } + + if (!$this->ldapConnectionService->isUserOnLDAPBackend($user)) { + throw new Exception("User is not on LDAP backend"); + } + + // Get user metadata from LDAP + $userMetadata = $this->ldapConnectionService->getUserMetadata($username); + + // Use usernameWithoutDomain from LDAP metadata + $usernameWithoutDomain = $userMetadata['usernameWithoutDomain']; + + // Convert LDAP createTimestamp to MySQL datetime format + $ldapTimestamp = $userMetadata['createTimestamp'] ?? null; + $createdAt = $ldapTimestamp; + + if ($ldapTimestamp) { + $dateTime = \DateTime::createFromFormat('YmdHis\Z', $ldapTimestamp, new \DateTimeZone('UTC')); + if ($dateTime !== false) { + $createdAt = $dateTime->format('Y-m-d H:i:s'); // Format compatible with MySQL + } + } + + $output->writeln(sprintf(' Found user in LDAP: %s (created: %s)', $usernameWithoutDomain, $createdAt)); + + if (!$isDryRun) { + // Add user to common database + $this->userService->addUsernameToCommonDataStore( + $usernameWithoutDomain, + $ipAddress, + $userMetadata['recoveryMailAddress'], + $createdAt + ); + $output->writeln(sprintf(' ✓ User synced successfully to common database: %s', $usernameWithoutDomain)); + } else { + $output->writeln(sprintf(' Would sync user to common database with recovery email: %s and created_at: %s', $userMetadata['recoveryMailAddress'], $createdAt)); + } + } +} diff --git a/lib/Service/LDAPConnectionService.php b/lib/Service/LDAPConnectionService.php index b63fb339fa4cf322e9d1063560ab874d833ef4ed..48a173da47ad4293a006b9e81c15d97c92446667 100644 --- a/lib/Service/LDAPConnectionService.php +++ b/lib/Service/LDAPConnectionService.php @@ -161,4 +161,52 @@ class LDAPConnectionService { return $mailAddress; } + + /** + * Get comprehensive user metadata from LDAP + * + * @param string $username The username to search for + * @return array User metadata including displayName, recoveryMailAddress, etc. + * @throws Exception If LDAP is not enabled or user not found + */ + public function getUserMetadata(string $username): array { + if (!$this->isLDAPEnabled()) { + throw new Exception('LDAP backend is not enabled'); + } + + $conn = $this->getLDAPConnection(); + $userDn = $this->username2dn($username); + + if ($userDn === false) { + throw new Exception('Could not find DN for username: ' . $username); + } + + $attributes = [ + 'createTimestamp', + 'usernameWithoutDomain', + 'recoveryMailAddress' + ]; + + $result = ldap_read($conn, $userDn, '(objectClass=*)', $attributes); + + if (!$result) { + $this->closeLDAPConnection($conn); + throw new Exception('Could not read user entry from LDAP for username: ' . $username); + } + + $entries = ldap_get_entries($conn, $result); + $this->closeLDAPConnection($conn); + + if ($entries['count'] === 0) { + throw new Exception('User not found in LDAP: ' . $username); + } + + $entry = $entries[0]; + + return [ + 'createTimestamp' => $entry['createtimestamp'][0] ?? '', + 'usernameWithoutDomain' => $entry['usernamewithoutdomain'][0], + 'recoveryMailAddress' => $entry['recoverymailaddress'][0] ?? '' + ]; + } } diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index 1cd8fece1ec226d3a3c020c7650493d7b9104683..049664665da0986209da40cda45a11804b19b941 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -439,10 +439,11 @@ class UserService { * @param string $username The username to add to the common data store. * @param string $ipAddress IP Address of user * @param string $recoveryEmail A recovery Email of user + * @param string|null $createdAt The creation timestamp (optional, defaults to current time) * * @throws AddUsernameToCommonStoreException If an error occurs while adding the username to the common data store. */ - public function addUsernameToCommonDataStore(string $username, string $ipAddress, string $recoveryEmail) : void { + public function addUsernameToCommonDataStore(string $username, string $ipAddress, string $recoveryEmail, ?string $createdAt = null) : void { $commonServicesURL = $this->apiConfig['commonServicesURL']; $commonApiVersion = $this->apiConfig['commonApiVersion']; @@ -458,6 +459,11 @@ class UserService { 'recoveryEmail' => $recoveryEmail ]; + // Add createdAt parameter if provided, otherwise let the API use current time + if ($createdAt !== null) { + $params['created_at'] = $createdAt; + } + $token = $this->apiConfig['commonServicesToken']; $headers = [ "Authorization: Bearer $token" @@ -484,4 +490,11 @@ class UserService { private function getDefaultQuota() { return $this->config->getSystemValueInt('default_quota_in_megabytes', 1024); } + /** + * Remove legacy domain from username if present + */ + public function stripLegacyDomainFromUsername(string $username): string { + $legacyDomain = $this->config->getSystemValue('legacy_domain', ''); + return str_ireplace('@' . $legacyDomain, '', $username); + } }