option('legacy-connection')) ?: 'legacy'; $legacyUsersTable = trim((string) $this->option('legacy-users-table')) ?: 'users'; $newConnection = trim((string) $this->option('new-connection')) ?: (string) config('database.default'); $newUsersTable = trim((string) $this->option('new-users-table')) ?: 'users'; $sqlOutputPath = trim((string) $this->option('sql-output')) ?: null; $chunkSize = max(1, (int) $this->option('chunk')); try { DB::connection($legacyConnection)->getPdo(); } catch (\Throwable $exception) { $this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $exception->getMessage()); return self::FAILURE; } try { DB::connection($newConnection)->getPdo(); } catch (\Throwable $exception) { $this->error("Cannot connect to new database connection [{$newConnection}]: " . $exception->getMessage()); return self::FAILURE; } if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable($legacyUsersTable)) { $this->error("Legacy users table [{$legacyConnection}.{$legacyUsersTable}] does not exist."); return self::FAILURE; } if (! DB::connection($newConnection)->getSchemaBuilder()->hasTable($newUsersTable)) { $this->error("New users table [{$newConnection}.{$newUsersTable}] does not exist."); return self::FAILURE; } $this->line(sprintf( 'Scanning %s.%s for should_migrate=1 and checking %s.%s...', $legacyConnection, $legacyUsersTable, $newConnection, $newUsersTable, )); $legacySelectColumns = $this->legacySelectColumns($legacyConnection, $legacyUsersTable); $scanned = 0; $existing = 0; $missing = 0; $sqlInsertCount = 0; $sqlContext = $sqlOutputPath !== null ? $this->startSqlExport($sqlOutputPath, $legacyConnection, $legacyUsersTable, $newUsersTable) : null; DB::connection($legacyConnection) ->table($legacyUsersTable) ->select($legacySelectColumns) ->where('should_migrate', 1) ->orderBy('user_id') ->chunkById($chunkSize, function ($rows) use (&$scanned, &$existing, &$missing, &$sqlInsertCount, &$sqlContext, $newConnection, $newUsersTable): void { $legacyIds = $rows->pluck('user_id') ->map(static fn (mixed $value): int => (int) $value) ->filter(static fn (int $value): bool => $value > 0) ->values() ->all(); $existingIds = DB::connection($newConnection) ->table($newUsersTable) ->whereIn('id', $legacyIds) ->pluck('id') ->map(static fn (mixed $value): int => (int) $value) ->flip(); foreach ($rows as $row) { $legacyId = (int) ($row->user_id ?? 0); if ($legacyId <= 0) { continue; } $scanned++; if ($existingIds->has($legacyId)) { $existing++; continue; } $missing++; $username = trim((string) ($row->uname ?? '')); $email = trim((string) ($row->email ?? '')); $this->line(sprintf( '[missing] id=%d uname=%s email=%s', $legacyId, $username !== '' ? '@' . $username : '(none)', $email !== '' ? '<' . $email . '>' : '(none)', )); if ($sqlContext !== null) { $statement = $this->buildMissingUserInsertStatement($row, $legacyId, $newConnection, $newUsersTable, $sqlContext); $this->appendSqlExport($sqlContext['path'], $statement . PHP_EOL); $sqlInsertCount++; } } }, 'user_id', 'user_id'); if ($sqlContext !== null) { $this->finishSqlExport($sqlContext['path'], $sqlInsertCount); $this->info(sprintf('SQL export written to %s with %d INSERT statement(s).', $sqlContext['path'], $sqlInsertCount)); } $this->newLine(); $this->info(sprintf( 'Done. scanned=%d existing=%d missing=%d', $scanned, $existing, $missing, )); return $missing > 0 ? self::FAILURE : self::SUCCESS; } /** * @return array */ private function legacySelectColumns(string $legacyConnection, string $legacyUsersTable): array { $available = []; foreach (DB::connection($legacyConnection)->getSchemaBuilder()->getColumnListing($legacyUsersTable) as $column) { $available[strtolower((string) $column)] = (string) $column; } $select = []; foreach (['user_id', 'uname', 'email', 'real_name', 'joinDate', 'LastVisit', 'active'] as $wanted) { $key = strtolower($wanted); if (isset($available[$key])) { $select[] = $available[$key]; } } return $select; } /** * @return array{path:string, generated_at:Carbon, reserved_usernames: array, reserved_emails: array} */ private function startSqlExport(string $sqlOutputPath, string $legacyConnection, string $legacyUsersTable, string $newUsersTable): array { $directory = dirname($sqlOutputPath); if ($directory !== '' && $directory !== '.' && ! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) { throw new \RuntimeException(sprintf('Could not create SQL output directory [%s].', $directory)); } $generatedAt = now(); $header = [ '-- Generated by users:audit-missing-migrated', sprintf('-- Generated at: %s', $generatedAt->toIso8601String()), sprintf('-- Legacy source: %s.%s', $legacyConnection, $legacyUsersTable), sprintf('-- Target table: %s', $newUsersTable), 'START TRANSACTION;', '', ]; $this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $header)); return [ 'path' => $sqlOutputPath, 'generated_at' => $generatedAt, 'reserved_usernames' => [], 'reserved_emails' => [], ]; } private function finishSqlExport(string $sqlOutputPath, int $insertCount): void { $footer = [ '', sprintf('-- Total INSERT statements: %d', $insertCount), 'COMMIT;', '', ]; $this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $footer)); } private function appendSqlExport(string $sqlOutputPath, string $content): void { $result = @file_put_contents($sqlOutputPath, $content, FILE_APPEND); if ($result === false) { throw new \RuntimeException(sprintf('Could not write SQL output file [%s].', $sqlOutputPath)); } } /** * @param array{path:string, generated_at:Carbon, reserved_usernames: array, reserved_emails: array} $sqlContext */ private function buildMissingUserInsertStatement(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array &$sqlContext): string { $generatedAt = $sqlContext['generated_at']; $username = $this->resolveSqlImportUsername($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_usernames']); $email = $this->resolveSqlImportEmail($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_emails']); $name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?: $username)); $createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $generatedAt->copy(); $lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit')); $isActive = (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1 ? 1 : 0; $passwordHash = Hash::make(Str::random(64)); $sqlContext['reserved_usernames'][strtolower($username)] = true; $sqlContext['reserved_emails'][strtolower($email)] = true; $columns = [ 'id', 'username', 'username_changed_at', 'name', 'email', 'password', 'is_active', 'needs_password_reset', 'role', 'legacy_password_algo', 'last_visit_at', 'created_at', 'updated_at', ]; $values = [ $legacyId, $username, $generatedAt->format('Y-m-d H:i:s'), $name, $email, $passwordHash, $isActive, 1, 'user', null, $lastVisitAt?->format('Y-m-d H:i:s'), $createdAt->format('Y-m-d H:i:s'), $generatedAt->format('Y-m-d H:i:s'), ]; return sprintf( 'INSERT INTO %s (%s) VALUES (%s);', $this->quoteIdentifier($newUsersTable), implode(', ', array_map(fn (string $column): string => $this->quoteIdentifier($column), $columns)), implode(', ', array_map(fn (mixed $value): string => $this->sqlLiteral($value), $values)), ); } /** * @param array $reservedUsernames */ private function resolveSqlImportUsername(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): string { $rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId)); $candidate = UsernamePolicy::sanitizeLegacy($rawUsername); if (! $this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) { return $candidate; } $base = 'tmpu' . $legacyId; $candidate = $base; $suffix = 1; while ($this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) { $suffixStr = (string) $suffix; $candidate = substr($base, 0, max(1, UsernamePolicy::max() - strlen($suffixStr))) . $suffixStr; $suffix++; } return $candidate; } /** * @param array $reservedUsernames */ private function sqlUsernameExists(string $username, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): bool { $normalized = strtolower(trim($username)); if ($normalized === '' || isset($reservedUsernames[$normalized])) { return true; } return DB::connection($newConnection) ->table($newUsersTable) ->whereRaw('LOWER(username) = ?', [$normalized]) ->where('id', '!=', $legacyId) ->exists(); } /** * @param array $reservedEmails */ private function resolveSqlImportEmail(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): string { $rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? ''))); $seed = $rawEmail !== '' ? $rawEmail : ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org'); $candidate = $seed; $suffix = 1; while ($this->sqlEmailExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedEmails)) { [$local, $domain] = array_pad(explode('@', $seed, 2), 2, 'users.skinbase.org'); $candidate = $this->sanitizeEmailLocal($local) . '+' . $suffix . '@' . $domain; $suffix++; } return $candidate; } /** * @param array $reservedEmails */ private function sqlEmailExists(string $email, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): bool { $normalized = strtolower(trim($email)); if ($normalized === '' || isset($reservedEmails[$normalized])) { return true; } return DB::connection($newConnection) ->table($newUsersTable) ->whereRaw('LOWER(email) = ?', [$normalized]) ->where('id', '!=', $legacyId) ->exists(); } private function sanitizeEmailLocal(string $value): string { $local = strtolower(trim(Str::ascii($value))); $local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user'; return trim($local, '.-') ?: 'user'; } private function legacyField(object $legacyUser, string $field): mixed { if (property_exists($legacyUser, $field)) { return $legacyUser->{$field}; } foreach ((array) $legacyUser as $key => $value) { if (strcasecmp((string) $key, $field) === 0) { return $value; } } return null; } private function parseLegacyDate(mixed $value): ?Carbon { if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) { return null; } try { return Carbon::parse($value); } catch (\Throwable) { return null; } } private function quoteIdentifier(string $identifier): string { return implode('.', array_map(static fn (string $segment): string => '`' . str_replace('`', '``', $segment) . '`', explode('.', $identifier))); } private function sqlLiteral(mixed $value): string { if ($value === null) { return 'NULL'; } if (is_bool($value)) { return $value ? '1' : '0'; } if (is_int($value) || is_float($value)) { return (string) $value; } return "'" . str_replace("'", "''", (string) $value) . "'"; } }