option('chunk')); $showMissing = max(0, (int) $this->option('show-missing')); $copyMissingFromLegacy = (bool) $this->option('copy-missing-from-legacy'); $createPlaceholder = (bool) $this->option('create-placeholder'); $dryRunCopy = (bool) $this->option('dry-run-copy'); $legacyConnection = (string) $this->option('legacy-connection'); $legacyUsersTable = (string) $this->option('legacy-users-table'); $artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null; $this->line(sprintf('Auditing artworks.user_id references in chunks of %d...', $chunkSize)); $audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId); $copySummary = null; if ($copyMissingFromLegacy) { $this->newLine(); $this->line(sprintf( '%s missing referenced users from legacy connection "%s" table "%s".', $dryRunCopy ? 'Previewing copy of' : 'Copying', $legacyConnection, $legacyUsersTable, )); try { $copySummary = $this->copyMissingUsersFromLegacy( array_keys($audit['missing_user_ids']), $legacyConnection, $legacyUsersTable, $dryRunCopy, $createPlaceholder, ); } catch (\Throwable $exception) { $this->error($exception->getMessage()); return self::FAILURE; } if (! $dryRunCopy && ($copySummary['copied'] ?? 0) > 0) { $audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId); } } if ((bool) $this->option('json')) { $payload = [ 'summary' => $audit['summary'], 'sample_missing' => $audit['sample_missing'], ]; if ($copySummary !== null) { $payload['copy_summary'] = $copySummary; } $this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE; } $this->renderAuditSummary($audit['summary'], $audit['sample_missing']); if ($copySummary !== null) { $this->renderCopySummary($copySummary); } return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE; } /** * @return array{ * summary: array{checked:int, valid:int, missing:int, null_user_ids:int}, * sample_missing: array, * missing_user_ids: array * } */ private function auditArtworkUserReferences(int $chunkSize, int $showMissing, ?int $artworkId = null): array { $checked = 0; $valid = 0; $missing = 0; $nullUserIds = 0; $sampleRows = []; $missingUserIds = []; DB::table('artworks') ->leftJoin('users', 'users.id', '=', 'artworks.user_id') ->select([ 'artworks.id', 'artworks.user_id', 'artworks.title', 'artworks.is_public', 'artworks.published_at', 'artworks.artwork_status', DB::raw('users.id as matched_user_id'), ]) ->when($artworkId !== null, fn ($q) => $q->where('artworks.id', $artworkId)) ->orderBy('artworks.id') ->chunkById($chunkSize, function ($artworks) use (&$checked, &$valid, &$missing, &$nullUserIds, &$sampleRows, &$missingUserIds, $showMissing): void { foreach ($artworks as $artwork) { $checked++; if ($artwork->matched_user_id !== null) { $valid++; continue; } $missing++; if ($artwork->user_id === null) { $nullUserIds++; } else { $missingUserIds[(int) $artwork->user_id] = true; } if (count($sampleRows) < $showMissing) { $sampleRows[] = [ 'artwork_id' => (int) $artwork->id, 'user_id' => $artwork->user_id === null ? '[null]' : (string) $artwork->user_id, 'title' => (string) ($artwork->title ?? ''), 'active' => (bool) ($artwork->is_public ?? false) ? 'yes' : 'no', 'published' => $artwork->published_at !== null ? 'yes' : 'no', 'artwork_status' => (string) ($artwork->artwork_status ?? ''), ]; } } if ($this->isVerboseOutput()) { $this->line(sprintf( ' audited %d artworks so far; missing=%d, null_user_id=%d.', $checked, $missing, $nullUserIds, )); } }, 'artworks.id', 'id'); return [ 'summary' => [ 'checked' => $checked, 'valid' => $valid, 'missing' => $missing, 'null_user_ids' => $nullUserIds, ], 'sample_missing' => $sampleRows, 'missing_user_ids' => $missingUserIds, ]; } /** * @param array $legacyIds * @return array */ private function copyMissingUsersFromLegacy(array $legacyIds, string $legacyConnection, string $legacyUsersTable, bool $dryRun, bool $createPlaceholder = false): array { $result = [ 'requested_users' => count($legacyIds), 'copied' => 0, 'placeholders_created' => 0, 'would_copy' => 0, 'conflicts' => 0, 'not_found_in_legacy' => 0, 'errors' => 0, 'dry_run' => $dryRun, 'sample_copied_ids' => [], 'sample_placeholder_ids' => [], 'sample_conflict_ids' => [], 'sample_not_found_ids' => [], 'sample_error_messages' => [], ]; if ($legacyIds === []) { if ($this->isVerboseOutput()) { $this->line('No missing non-null user ids were found to copy from the legacy users table.'); } return $result; } $this->ensureLegacyConnectionIsUsable($legacyConnection, $legacyUsersTable); $normalizedLegacyIds = array_values(array_unique(array_map('intval', $legacyIds))); foreach (array_chunk($normalizedLegacyIds, 200) as $chunkIndex => $chunk) { $legacyRows = DB::connection($legacyConnection) ->table($legacyUsersTable) ->whereIn('user_id', $chunk) ->get() ->keyBy(fn (object $row): int => (int) $row->user_id); if ($this->isVerboseOutput()) { $this->line(sprintf( ' processing legacy chunk %d with %d requested ids; found %d legacy rows.', $chunkIndex + 1, count($chunk), $legacyRows->count(), )); } foreach ($chunk as $legacyId) { if (DB::table('users')->where('id', $legacyId)->exists()) { $result['conflicts']++; if (count($result['sample_conflict_ids']) < 10) { $result['sample_conflict_ids'][] = $legacyId; } if ($this->isVerboseOutput()) { $this->warn(sprintf('[skip-conflict] user #%d already exists in the new users table.', $legacyId)); } continue; } $legacyUser = $legacyRows->get($legacyId); if (! $legacyUser) { $result['not_found_in_legacy']++; if (count($result['sample_not_found_ids']) < 10) { $result['sample_not_found_ids'][] = $legacyId; } if ($this->isVerboseOutput()) { $this->warn(sprintf( '[missing-legacy] user #%d was not found in %s.%s.', $legacyId, $legacyConnection, $legacyUsersTable, )); } if ($createPlaceholder) { if ($dryRun) { $result['would_copy']++; $this->line(sprintf('[dry-run] would create placeholder tmpu%d for user #%d', $legacyId, $legacyId)); } else { try { $this->createPlaceholderUser($legacyId); $result['placeholders_created']++; if (count($result['sample_placeholder_ids']) < 10) { $result['sample_placeholder_ids'][] = $legacyId; } $this->info(sprintf('[placeholder] created tmpu%d for user #%d', $legacyId, $legacyId)); } catch (\Throwable $exception) { $result['errors']++; $message = sprintf('#%d (placeholder): %s', $legacyId, $exception->getMessage()); if (count($result['sample_error_messages']) < 10) { $result['sample_error_messages'][] = $message; } $this->error('[placeholder-error] ' . $message); } } } continue; } if ($dryRun) { $result['would_copy']++; if (count($result['sample_copied_ids']) < 10) { $result['sample_copied_ids'][] = $legacyId; } $this->line(sprintf('[dry-run] would import legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId))); continue; } try { $this->importLegacyUserBySameId($legacyUser, $legacyId); $result['copied']++; if (count($result['sample_copied_ids']) < 10) { $result['sample_copied_ids'][] = $legacyId; } $this->info(sprintf('[copied] imported legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId))); } catch (\Throwable $exception) { $result['errors']++; $message = sprintf('#%d: %s', $legacyId, $exception->getMessage()); if (count($result['sample_error_messages']) < 10) { $result['sample_error_messages'][] = $message; } $this->error('[copy-error] ' . $message); } } } return $result; } private function importLegacyUserBySameId(object $legacyUser, int $legacyId): void { $now = now(); $username = $this->resolveImportUsername($legacyUser, $legacyId); $email = $this->resolveImportEmail($legacyUser, $legacyId); $name = (string) ($this->legacyField($legacyUser, 'real_name') ?: $username); $createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $now; $lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit')); $countryCode = $this->legacyField($legacyUser, 'country_code'); DB::transaction(function () use ($legacyId, $legacyUser, $username, $email, $name, $createdAt, $lastVisitAt, $countryCode, $now): void { if (DB::table('users')->where('id', $legacyId)->exists()) { throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId)); } DB::table('users')->insert([ 'id' => $legacyId, 'username' => $username, 'username_changed_at' => $now, 'name' => $name, 'email' => $email, 'password' => Hash::make(Str::random(64)), 'is_active' => (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1, 'needs_password_reset' => true, 'role' => 'user', 'legacy_password_algo' => null, 'last_visit_at' => $lastVisitAt, 'created_at' => $createdAt, 'updated_at' => $now, ]); if (Schema::hasTable('user_profiles')) { DB::table('user_profiles')->updateOrInsert( ['user_id' => $legacyId], [ 'bio' => $this->legacyField($legacyUser, 'about_me') ?: $this->legacyField($legacyUser, 'description'), 'country' => $this->legacyField($legacyUser, 'country'), 'country_code' => is_string($countryCode) && $countryCode !== '' ? substr($countryCode, 0, 2) : null, 'website' => $this->legacyField($legacyUser, 'web'), 'gender' => $this->normalizeLegacyGender($this->legacyField($legacyUser, 'gender')), 'created_at' => $now, 'updated_at' => $now, ], ); } if (Schema::hasTable('user_statistics')) { DB::table('user_statistics')->updateOrInsert( ['user_id' => $legacyId], [ 'uploads_count' => 0, 'downloads_received_count' => 0, 'artwork_views_received_count' => 0, 'awards_received_count' => 0, 'created_at' => $now, 'updated_at' => $now, ], ); } }); } private function createPlaceholderUser(int $legacyId): void { $now = now(); $username = $this->uniquePlaceholderUsername($legacyId); $email = $username . '@users.skinbase.org'; DB::transaction(function () use ($legacyId, $username, $email, $now): void { if (DB::table('users')->where('id', $legacyId)->exists()) { throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId)); } DB::table('users')->insert([ 'id' => $legacyId, 'username' => $username, 'username_changed_at' => $now, 'name' => $username, 'email' => $email, 'password' => Hash::make(Str::random(64)), 'is_active' => false, 'needs_password_reset' => true, 'role' => 'user', 'legacy_password_algo' => null, 'last_visit_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); if (Schema::hasTable('user_profiles')) { DB::table('user_profiles')->updateOrInsert( ['user_id' => $legacyId], ['created_at' => $now, 'updated_at' => $now], ); } if (Schema::hasTable('user_statistics')) { DB::table('user_statistics')->updateOrInsert( ['user_id' => $legacyId], [ 'uploads_count' => 0, 'downloads_received_count' => 0, 'artwork_views_received_count' => 0, 'awards_received_count' => 0, 'created_at' => $now, 'updated_at' => $now, ], ); } }); } private function resolveImportUsername(object $legacyUser, int $legacyId): string { $rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId)); $username = $this->sanitizeUsername($rawUsername); if (! $this->usernameExists($username, $legacyId)) { return $username; } return $this->uniquePlaceholderUsername($legacyId); } private function sanitizeUsername(string $username): string { return UsernamePolicy::sanitizeLegacy($username); } private function usernameExists(string $username, int $ignoreUserId): bool { return DB::table('users') ->whereRaw('LOWER(username) = ?', [strtolower($username)]) ->where('id', '!=', $ignoreUserId) ->exists(); } private function uniquePlaceholderUsername(int $legacyId): string { $base = 'tmpu' . $legacyId; $candidate = $base; $suffix = 1; while ($this->usernameExists($candidate, $legacyId)) { $suffixStr = (string) $suffix; $candidate = substr($base, 0, max(1, 20 - strlen($suffixStr))) . $suffixStr; $suffix++; } return $candidate; } private function renderAuditSummary(array $summary, array $sampleRows): void { $this->info(sprintf( 'Checked %d artworks: %d valid, %d missing user references, %d null user_id values.', (int) ($summary['checked'] ?? 0), (int) ($summary['valid'] ?? 0), (int) ($summary['missing'] ?? 0), (int) ($summary['null_user_ids'] ?? 0), )); if ($sampleRows !== []) { $this->newLine(); $this->warn('Sample missing references:'); $this->table(['Artwork ID', 'user_id', 'Title', 'Active', 'Published', 'Status'], array_map( static fn (array $row): array => [ $row['artwork_id'], $row['user_id'], $row['title'], $row['active'], $row['published'], $row['artwork_status'], ], $sampleRows, )); } if ((int) ($summary['missing'] ?? 0) === 0) { $this->info('No missing user references found in artworks.user_id.'); } else { $this->error('Found artworks with missing user references.'); } } private function renderCopySummary(array $copySummary): void { $this->newLine(); $this->info(sprintf( 'Legacy copy summary: requested %d users, copied %d, placeholders %d, would copy %d, conflicts %d, not found in legacy %d, errors %d.', (int) ($copySummary['requested_users'] ?? 0), (int) ($copySummary['copied'] ?? 0), (int) ($copySummary['placeholders_created'] ?? 0), (int) ($copySummary['would_copy'] ?? 0), (int) ($copySummary['conflicts'] ?? 0), (int) ($copySummary['not_found_in_legacy'] ?? 0), (int) ($copySummary['errors'] ?? 0), )); if (($copySummary['sample_copied_ids'] ?? []) !== []) { $this->line('Copied or would-copy user ids: ' . implode(', ', $copySummary['sample_copied_ids'])); } if (($copySummary['sample_placeholder_ids'] ?? []) !== []) { $this->line('Placeholder users created for ids: ' . implode(', ', $copySummary['sample_placeholder_ids'])); } if (($copySummary['sample_conflict_ids'] ?? []) !== []) { $this->warn('Conflicts: user ids already present in new DB: ' . implode(', ', $copySummary['sample_conflict_ids'])); } if (($copySummary['sample_not_found_ids'] ?? []) !== []) { $this->warn('Not found in legacy: ' . implode(', ', $copySummary['sample_not_found_ids'])); } if (($copySummary['sample_error_messages'] ?? []) !== []) { foreach ($copySummary['sample_error_messages'] as $message) { $this->warn($message); } } } private function ensureLegacyConnectionIsUsable(string $connection, string $table): void { try { DB::connection($connection)->getPdo(); } catch (\Throwable $exception) { throw new RuntimeException(sprintf('Legacy DB connection "%s" is not configured or reachable.', $connection), 0, $exception); } if (! DB::connection($connection)->getSchemaBuilder()->hasTable($table)) { throw new RuntimeException(sprintf('Legacy users table "%s" was not found on connection "%s".', $table, $connection)); } } private function resolveImportEmail(object $legacyUser, int $legacyId): string { $rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? ''))); $candidate = $rawEmail !== '' ? $rawEmail : ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org'); return $this->uniqueEmailCandidate($candidate, $legacyId); } private function uniqueEmailCandidate(string $email, int $legacyId): string { $candidate = strtolower(trim($email)); $suffix = 1; while ($candidate === '' || DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->where('id', '!=', $legacyId)->exists()) { $parts = explode('@', $email, 2); $local = $this->sanitizeEmailLocal($parts[0] ?? ('user' . $legacyId)); $domain = $parts[1] ?? 'users.skinbase.org'; $candidate = $local . '+' . $suffix . '@' . $domain; $suffix++; } return $candidate; } 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 normalizeLegacyGender(mixed $value): ?string { $normalized = strtoupper(trim((string) ($value ?? ''))); return match ($normalized) { 'M', 'MALE', 'MAN', 'BOY' => 'M', 'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F', default => null, }; } private function isVerboseOutput(): bool { return $this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE; } private function describeLegacyUser(object $legacyUser, int $legacyId): string { $username = trim((string) ($this->legacyField($legacyUser, 'uname') ?? '')); $name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?? '')); $email = trim((string) ($this->legacyField($legacyUser, 'email') ?? '')); return sprintf( '#%d username=%s name=%s email=%s', $legacyId, $username !== '' ? '@' . $username : '[missing]', $name !== '' ? '"' . $name . '"' : '[missing]', $email !== '' ? '<' . $email . '>' : '[missing]', ); } 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 legacyField(object $row, string $field): mixed { return property_exists($row, $field) ? $row->{$field} : null; } }