Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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) . "'";
}
}