344 lines
14 KiB
PHP
344 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Support\UsernamePolicy;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ImportLegacyUsers extends Command
|
|
{
|
|
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
|
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
|
|
|
protected string $migrationLogPath;
|
|
/** @var array<int,true> Legacy user IDs that qualify for import */
|
|
protected array $activeUserIds = [];
|
|
|
|
public function handle(): int
|
|
{
|
|
$this->migrationLogPath = (string) 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<int,true>
|
|
*/
|
|
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 by default. Placeholder tmp usernames can be
|
|
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
|
|
$existingUser = DB::table('users')
|
|
->select(['id', 'username'])
|
|
->where('id', $legacyId)
|
|
->first();
|
|
|
|
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
|
|
|
|
$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();
|
|
$existingUser = DB::table('users')
|
|
->select(['id', 'username'])
|
|
->where('id', $legacyId)
|
|
->first();
|
|
$alreadyExists = $existingUser !== null;
|
|
$previousUsername = (string) ($existingUser?->username ?? '');
|
|
|
|
// 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' => $this->normalizeLegacyGender($row->gender ?? null),
|
|
'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_count' => $uploads,
|
|
'downloads_received_count' => $downloads,
|
|
'artwork_views_received_count' => $pageviews,
|
|
'awards_received_count' => $awards,
|
|
'updated_at' => $now,
|
|
]
|
|
);
|
|
|
|
if (Schema::hasTable('username_redirects')) {
|
|
$old = $this->usernameRedirectKey((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,
|
|
]
|
|
);
|
|
}
|
|
|
|
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
|
|
DB::table('username_redirects')->updateOrInsert(
|
|
['old_username' => $this->usernameRedirectKey($previousUsername)],
|
|
[
|
|
'new_username' => $username,
|
|
'user_id' => $legacyId,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
|
|
{
|
|
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
|
|
|
if (! $this->option('restore-temp-usernames')) {
|
|
return $legacyUsername;
|
|
}
|
|
|
|
if ($existingUsername === null || $existingUsername === '') {
|
|
return $legacyUsername;
|
|
}
|
|
|
|
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
|
|
return $existingUsername;
|
|
}
|
|
|
|
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
|
|
}
|
|
|
|
protected function shouldRestoreTemporaryUsername(?string $username): bool
|
|
{
|
|
if (! is_string($username) || trim($username) === '') {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
|
|
}
|
|
|
|
/**
|
|
* 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 usernameRedirectKey(?string $username): string
|
|
{
|
|
$value = $this->sanitizeUsername((string) ($username ?? ''));
|
|
|
|
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
|
|
}
|
|
|
|
protected 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,
|
|
};
|
|
}
|
|
|
|
protected function sanitizeEmailLocal(string $value): string
|
|
{
|
|
$local = strtolower(trim($value));
|
|
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
|
return trim($local, '.-') ?: 'user';
|
|
}
|
|
}
|