Legacy user IDs that qualify for import */ protected array $activeUserIds = []; public function handle(): int { $this->migrationLogPath = storage_path('logs/username_migration.log'); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND); // Build the set of legacy user IDs that have any meaningful activity. // Users outside this set will be skipped (or deleted from the new DB if already imported). $this->activeUserIds = $this->buildActiveUserIds(); $this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds)); $chunk = (int) $this->option('chunk'); $dryRun = (bool) $this->option('dry-run'); $imported = 0; $skipped = 0; $purged = 0; if (! DB::connection('legacy')->getPdo()) { $this->error('Legacy DB connection "legacy" is not configured or reachable.'); return self::FAILURE; } DB::connection('legacy')->table('users') ->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) { $ids = $rows->pluck('user_id')->all(); $stats = DB::connection('legacy')->table('users_statistics') ->whereIn('user_id', $ids) ->get() ->keyBy('user_id'); foreach ($rows as $row) { $legacyId = (int) $row->user_id; // ── Inactive user: no uploads, no comments, no forum activity ── if (! isset($this->activeUserIds[$legacyId])) { // If already imported into the new DB, purge it. $existsInNew = DB::table('users')->where('id', $legacyId)->exists(); if ($existsInNew) { if ($dryRun) { $this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB"); } else { $this->purgeNewUser($legacyId); $this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB"); $purged++; } } else { $this->line("[skip] user_id={$legacyId} no activity — skipping"); } $skipped++; continue; } if ($dryRun) { $this->line("[dry] Would import user_id={$legacyId}"); $imported++; continue; } try { $this->importRow($row, $stats[$row->user_id] ?? null); $imported++; } catch (\Throwable $e) { $skipped++; $this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}"); } } }, 'user_id'); $this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}"); return self::SUCCESS; } /** * Build a lookup array of legacy user IDs that qualify for import: * — uploaded at least one artwork (users_statistics.uploads > 0) * — posted at least one artwork comment (artworks_comments.user_id) * — created or posted to a forum thread (forum_topics / forum_posts) * * @return array */ protected function buildActiveUserIds(): array { $rows = DB::connection('legacy')->select(" SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0 UNION SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0 UNION SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0 UNION SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0 "); $map = []; foreach ($rows as $r) { $map[(int) $r->user_id] = true; } return $map; } /** * Remove all new-DB records for a given legacy user ID. * Covers: users, user_profiles, user_statistics, username_redirects. */ protected function purgeNewUser(int $userId): void { DB::transaction(function () use ($userId) { DB::table('username_redirects')->where('user_id', $userId)->delete(); DB::table('user_statistics')->where('user_id', $userId)->delete(); DB::table('user_profiles')->where('user_id', $userId)->delete(); DB::table('users')->where('id', $userId)->delete(); }); } protected function importRow($row, $statRow = null): void { $legacyId = (int) $row->user_id; // Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB). $username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId))); $normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? '')); if ($normalizedLegacy !== $username) { @file_put_contents( $this->migrationLogPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username), FILE_APPEND ); } // Use the real legacy email; only synthesise a placeholder when missing. $rawEmail = $row->email ? strtolower(trim($row->email)) : null; $email = $rawEmail ?: ($this->sanitizeEmailLocal($username) . '@users.skinbase.org'); $legacyPassword = $row->password2 ?: $row->password ?: null; // Optionally force-reset every imported user's password to a secure random value. if ($this->option('force-reset-all')) { $this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}."); $passwordHash = Hash::make(Str::random(64)); } else { // Force-reset known weak default passwords (e.g. "abc123"). if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') { $this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset."); $passwordHash = Hash::make(Str::random(64)); } else { $passwordHash = Hash::make($legacyPassword ?: Str::random(32)); } } $uploads = $this->sanitizeStatValue($statRow->uploads ?? 0); $downloads = $this->sanitizeStatValue($statRow->downloads ?? 0); $pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0); $awards = $this->sanitizeStatValue($statRow->awards ?? 0); DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { $now = now(); $alreadyExists = DB::table('users')->where('id', $legacyId)->exists(); // All fields synced from legacy on every run $sharedFields = [ 'username' => $username, 'username_changed_at' => $now, 'name' => $row->real_name ?: $username, 'email' => $email, 'is_active' => (int) ($row->active ?? 1) === 1, 'needs_password_reset' => true, 'role' => 'user', 'legacy_password_algo' => null, 'last_visit_at' => $row->LastVisit ?: null, 'updated_at' => $now, ]; if ($alreadyExists) { // Sync all fields from legacy — password is never overwritten on re-runs // (unless --force-reset-all was passed, in which case the caller handles it // separately outside this transaction). DB::table('users')->where('id', $legacyId)->update($sharedFields); } else { DB::table('users')->insert(array_merge($sharedFields, [ 'id' => $legacyId, 'password' => $passwordHash, 'created_at' => $row->joinDate ?: $now, ])); } DB::table('user_profiles')->updateOrInsert( ['user_id' => $legacyId], [ 'about' => $row->about_me ?: $row->description ?: null, 'avatar_legacy' => $row->picture ?: null, 'cover_image' => $row->cover_art ?: null, 'country' => $row->country ?: null, 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, 'language' => $row->lang ?: null, 'birthdate' => $row->birth ?: null, 'gender' => $row->gender ?: 'X', 'website' => $row->web ?: null, 'updated_at' => $now, ] ); // Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`. DB::table('user_statistics')->updateOrInsert( ['user_id' => $legacyId], [ 'uploads' => $uploads, 'downloads' => $downloads, 'pageviews' => $pageviews, 'awards' => $awards, 'updated_at' => $now, ] ); if (Schema::hasTable('username_redirects')) { $old = UsernamePolicy::normalize((string) ($row->uname ?? '')); if ($old !== '' && $old !== $username) { DB::table('username_redirects')->updateOrInsert( ['old_username' => $old], [ 'new_username' => $username, 'user_id' => $legacyId, 'created_at' => $now, 'updated_at' => $now, ] ); } } }); } /** * Ensure statistic values are safe for unsigned DB columns. */ protected function sanitizeStatValue($value): int { $n = is_numeric($value) ? (int) $value : 0; if ($n < 0) { return 0; } return $n; } protected function sanitizeUsername(string $username): string { return UsernamePolicy::sanitizeLegacy($username); } protected function sanitizeEmailLocal(string $value): string { $local = strtolower(trim($value)); $local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user'; return trim($local, '.-') ?: 'user'; } }