Save workspace changes
This commit is contained in:
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AuditMissingMigratedUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'users:audit-missing-migrated
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-users-table=users : Legacy users table name}
|
||||
{--new-connection= : New database connection name (defaults to the app default connection)}
|
||||
{--new-users-table=users : New users table name}
|
||||
{--sql-output= : Optional path for a transaction-wrapped SQL file with INSERTs for missing users}
|
||||
{--chunk=500 : Number of legacy users to process per chunk}';
|
||||
|
||||
protected $description = 'List legacy users flagged with should_migrate=1 that do not exist in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$legacyConnection = trim((string) $this->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<int, string>
|
||||
*/
|
||||
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<string, true>, reserved_emails: array<string, true>}
|
||||
*/
|
||||
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<string, true>, reserved_emails: array<string, true>} $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<string, true> $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<string, true> $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<string, true> $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<string, true> $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) . "'";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user