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);
+ }
}