Auth: convert auth views and verification email to Nova layout

This commit is contained in:
2026-02-21 07:37:08 +01:00
parent 93b009d42a
commit 795c7a835f
117 changed files with 5385 additions and 1291 deletions

View File

@@ -2,124 +2,308 @@
namespace App\Console\Commands;
use App\Services\AvatarService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
use Carbon\Carbon;
class AvatarsMigrate extends Command
{
protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate
{--dry-run : Do not write files or update database}
{--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan}
';
protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
public function __construct(private readonly AvatarService $service)
{
parent::__construct();
}
/**
* Allowed MIME types for source images.
*
* @var array
*/
protected $allowed = [
'image/jpeg',
'image/png',
'image/webp',
];
/**
* Target sizes to generate.
*
* @var int[]
*/
protected $sizes = [32, 64, 128, 256, 512];
public function handle(): int
{
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$dry = $this->option('dry-run');
$force = $this->option('force');
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$this->info('Starting avatar migration...');
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : ''));
$rows = DB::table('user_profiles as p')
->leftJoin('users as u', 'u.id', '=', 'p.user_id')
->select([
'p.user_id',
'p.avatar_hash',
'p.avatar_legacy',
'u.icon as user_icon',
])
->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
->where(function ($query) {
$query->whereNotNull('p.avatar_legacy')
->orWhereNotNull('u.icon');
})
->orderBy('p.user_id')
->when($limit > 0, fn ($query) => $query->limit($limit))
->get();
if ($rows->isEmpty()) {
$this->info('No avatars require migration.');
return self::SUCCESS;
// Detect processing backend: Intervention preferred, GD fallback
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
if ($useIntervention) {
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
}
$migrated = 0;
$skipped = 0;
$failed = 0;
$bar = null;
foreach ($rows as $row) {
$userId = (int) $row->user_id;
$legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon);
User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
if ($legacyName === null) {
$skipped++;
continue;
}
$path = $this->locateLegacyAvatarPath($userId, $legacyName);
if ($path === null) {
$failed++;
$this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
continue;
}
try {
$hash = $this->service->storeFromLegacyFile($userId, $path);
if (!$hash) {
$failed++;
$this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
if (!$profile) {
continue;
}
$migrated++;
$this->line("User {$userId}: migrated ({$hash})");
} catch (\Throwable $e) {
$failed++;
$this->warn("User {$userId}: migration failed ({$e->getMessage()})");
// Skip if already migrated unless --force
if (!$force && !empty($profile->avatar_hash)) {
$this->line("[skip] user={$user->id} already migrated");
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
if (!$source) {
$this->line("[noop] user={$user->id} no legacy file found");
continue;
}
try {
$this->line("[proc] user={$user->id} file={$source}");
if ($useIntervention) {
$img = Image::make($source);
$mime = $img->mime();
} else {
$info = @getimagesize($source);
$mime = $info['mime'] ?? null;
}
if (!in_array($mime, $this->allowed, true)) {
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
continue;
}
// Re-encode full original to webp (strip metadata)
if ($useIntervention) {
$originalBlob = (string) $img->encode('webp', 82);
} else {
$originalBlob = $this->gdEncodeWebp($source, 82);
}
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
$idPart = sha1(sprintf('%08d', $user->id));
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
if ($dry) {
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
} else {
// Use hash-based directory structure: avatars/ab/cd/{hash}/
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
Storage::disk('public')->makeDirectory($dir);
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
// Generate sizes
foreach ($this->sizes as $size) {
if ($useIntervention) {
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
$constraint->upsize();
});
$thumbBlob = (string) $thumb->encode('webp', 82);
} else {
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
}
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
}
// Update DB
$profile->avatar_hash = $hash;
$profile->avatar_mime = 'image/webp';
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$this->line("[ok] user={$user->id} migrated hash={$hash}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
if (file_exists($legacyFile)) {
@unlink($legacyFile);
$this->line("[rm] removed legacy file {$legacyFile}");
}
}
}
} catch (\Exception $e) {
$this->error("[error] user={$user->id} {$e->getMessage()}");
continue;
}
}
});
$this->info('Avatar migration complete');
return 0;
}
/**
* Try to find a legacy avatar file for a user/profile.
*
* @param UserProfile $profile
* @param int $userId
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
{
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
if (file_exists($p)) {
return $p;
}
}
$this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function normalizeLegacyName(?string $value): ?string
{
if (!$value) {
return null;
// 2) Try files named by user id with common extensions
$exts = ['png','jpg','jpeg','webp','gif'];
foreach ($exts as $ext) {
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
if (file_exists($p)) {
return $p;
}
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return basename(urldecode($trimmed));
}
private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
{
$candidates = [
public_path('avatar/' . $legacyName),
public_path('avatar/' . $userId . '/' . $legacyName),
public_path('user-picture/' . $legacyName),
storage_path('app/public/avatar/' . $legacyName),
storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
storage_path('app/public/user-picture/' . $legacyName),
base_path('oldSite/www/files/usericons/' . $legacyName),
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
return $candidate;
// 3) Try any file under legacy dir that contains the user id in name
if (is_dir($legacyBase)) {
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
if (!empty($files)) {
return $files[0];
}
}
return null;
}
/**
* GD-based encode to WebP binary blob.
*
* @param string $path
* @param int $quality
* @return string
*/
protected function gdEncodeWebp(string $path, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
ob_start();
imagewebp($src, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
return $data;
}
/**
* Create a center-cropped square thumbnail and return WebP binary.
*
* @param string $path
* @param int $size
* @param int $quality
* @return string
*/
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
$w = imagesx($src);
$h = imagesy($src);
$min = min($w, $h);
$srcX = (int) floor(($w - $min) / 2);
$srcY = (int) floor(($h - $min) / 2);
$dst = imagecreatetruecolor($size, $size);
// preserve transparency
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
ob_start();
imagewebp($dst, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return $data;
}
/**
* Create GD image resource from file path.
*
* @param string $path
* @return resource|false
*/
protected function gdCreateResource(string $path)
{
$info = @getimagesize($path);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
switch ($mime) {
case 'image/jpeg':
return imagecreatefromjpeg($path);
case 'image/png':
return imagecreatefrompng($path);
case 'image/webp':
if (function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
default:
return false;
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EnforceUsernamePolicy extends Command
{
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$logPath = storage_path('logs/username_migration.log');
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
$updated = 0;
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
foreach ($users as $user) {
$current = strtolower(trim((string) ($user->username ?? '')));
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
$base = 'user'.$user->id;
}
$candidate = substr($base, 0, UsernamePolicy::max());
$suffix = 1;
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
$needsUpdate = $candidate !== $current;
if (! $needsUpdate) {
$used[$candidate] = (int) $user->id;
continue;
}
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
if (! $dryRun) {
DB::transaction(function () use ($user, $current, $candidate): void {
if ($current !== '' && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $current,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($current !== '' && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $current],
[
'new_username' => $candidate,
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
DB::table('users')->where('id', (int) $user->id)->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
});
}
$used[$candidate] = (int) $user->id;
$updated++;
}
});
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
return self::SUCCESS;
}
}

View File

@@ -4,8 +4,10 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\ForumCategory;
use App\Models\User;
use App\Models\ForumThread;
use App\Models\ForumPost;
use Exception;
@@ -13,12 +15,19 @@ use App\Services\BbcodeConverter;
class ForumMigrateOld extends Command
{
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}';
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
protected string $logPath;
protected ?int $limit = null;
protected ?int $deletedUserId = null;
/** @var array<int,int> */
protected array $missingUserIds = [];
public function __construct()
{
parent::__construct();
@@ -33,6 +42,17 @@ class ForumMigrateOld extends Command
$dry = $this->option('dry-run');
$only = $this->option('only');
$chunk = (int)$this->option('chunk');
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
$only = $only === 'attachments' ? 'gallery' : $only;
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
return 1;
}
if ($chunk < 1) {
$chunk = 500;
}
try {
if (!$only || $only === 'categories') {
@@ -51,6 +71,10 @@ class ForumMigrateOld extends Command
$this->migrateGallery($dry, $chunk);
}
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
$this->repairOrphanPosts($dry);
}
if ($this->option('report')) {
$this->generateReport();
}
@@ -74,8 +98,13 @@ class ForumMigrateOld extends Command
->select('root_id')
->distinct()
->where('root_id', '>', 0)
->orderBy('root_id')
->pluck('root_id');
if ($this->limit !== null && $this->limit > 0) {
$roots = $roots->take($this->limit);
}
$this->info('Found ' . $roots->count() . ' legacy root ids');
foreach ($roots as $rootId) {
@@ -90,10 +119,12 @@ class ForumMigrateOld extends Command
continue;
}
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
DB::transaction(function () use ($rootId, $name, $slug) {
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
}, 3);
}
$this->info('Categories migrated');
@@ -107,15 +138,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics')->orderBy('topic_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total threads to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// chunk by legacy primary key `topic_id`
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->topic_id,
@@ -137,7 +179,9 @@ class ForumMigrateOld extends Command
continue;
}
ForumThread::updateOrCreate(['id' => $data['id']], $data);
DB::transaction(function () use ($data) {
ForumThread::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'topic_id');
@@ -153,15 +197,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_posts')->orderBy('post_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total posts to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// legacy forum_posts uses `post_id` as primary key
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->post_id,
@@ -177,7 +232,9 @@ class ForumMigrateOld extends Command
continue;
}
ForumPost::updateOrCreate(['id' => $data['id']], $data);
DB::transaction(function () use ($data) {
ForumPost::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'post_id');
@@ -243,15 +300,50 @@ class ForumMigrateOld extends Command
protected function resolveUserId($userId)
{
if (empty($userId)) {
return 1;
return $this->resolveDeletedUserId();
}
// check users table in default connection
if (\DB::table('users')->where('id', $userId)->exists()) {
if (DB::table('users')->where('id', $userId)->exists()) {
return $userId;
}
return 1; // fallback system user
$uid = (int) $userId;
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
$this->missingUserIds[] = $uid;
}
return $this->resolveDeletedUserId();
}
protected function resolveDeletedUserId(): int
{
if ($this->deletedUserId !== null) {
return $this->deletedUserId;
}
$userOne = User::query()->find(1);
if ($userOne) {
$this->deletedUserId = 1;
return $this->deletedUserId;
}
$fallback = User::query()->orderBy('id')->first();
if ($fallback) {
$this->deletedUserId = (int) $fallback->id;
return $this->deletedUserId;
}
$created = User::query()->create([
'name' => 'Deleted User',
'email' => 'deleted-user+forum@skinbase.local',
'password' => Hash::make(Str::random(64)),
'role' => 'user',
]);
$this->deletedUserId = (int) $created->id;
return $this->deletedUserId;
}
protected function convertLegacyMessage($msg)
@@ -260,6 +352,114 @@ class ForumMigrateOld extends Command
return $converter->convert($msg);
}
protected function repairOrphanPosts(bool $dry): void
{
$this->info('Repairing orphan posts');
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
$orphanCount = (clone $orphansQuery)->count();
if ($orphanCount === 0) {
$this->info('No orphan posts found.');
return;
}
$this->warn("Found {$orphanCount} orphan posts.");
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
if ($repairThread === null) {
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
});
return;
}
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
if ($dry) {
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
});
return;
}
$updated = 0;
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
/** @var ForumPost $post */
foreach ($posts as $post) {
$post->thread_id = $repairThread->id;
$post->is_edited = true;
$post->edited_at = $post->edited_at ?: now();
$post->save();
$updated++;
}
}, 3);
}, 'id');
$latestPostAt = ForumPost::query()
->where('thread_id', $repairThread->id)
->max('created_at');
if ($latestPostAt) {
$repairThread->last_post_at = $latestPostAt;
$repairThread->save();
}
$this->info("Repaired orphan posts: {$updated}");
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
}
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
{
$slug = 'migration-orphaned-posts';
$existing = ForumThread::query()->where('slug', $slug)->first();
if ($existing) {
return $existing;
}
$category = ForumCategory::query()->ordered()->first();
if (!$category && !$dry) {
$category = ForumCategory::query()->create([
'name' => 'Migration Repairs',
'slug' => 'migration-repairs',
'parent_id' => null,
'position' => 9999,
]);
}
if (!$category) {
return null;
}
if ($dry) {
return new ForumThread([
'id' => 0,
'slug' => $slug,
'category_id' => $category->id,
]);
}
return ForumThread::query()->create([
'category_id' => $category->id,
'user_id' => $this->resolveDeletedUserId(),
'title' => 'Migration: Orphaned Posts Recovery',
'slug' => $slug,
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'staff',
'last_post_at' => now(),
]);
}
protected function generateReport()
{
$this->info('Generating migration report');
@@ -275,12 +475,32 @@ class ForumMigrateOld extends Command
'categories' => ForumCategory::count(),
'threads' => ForumThread::count(),
'posts' => ForumPost::count(),
'attachments' => \DB::table('forum_attachments')->count(),
'attachments' => DB::table('forum_attachments')->count(),
];
$orphans = ForumPost::query()
->whereDoesntHave('thread')
->count();
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
$report = [
'missing_users_count' => count($this->missingUserIds),
'missing_users' => $this->missingUserIds,
'orphan_posts' => $orphans,
'timestamp_mismatches' => [
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
],
];
$this->info('Legacy counts: ' . json_encode($legacyCounts));
$this->info('New counts: ' . json_encode($newCounts));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts));
$this->info('Report: ' . json_encode($report));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
}
protected function log(string $msg)
@@ -301,14 +521,25 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total gallery items to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$processed = 0;
$limit = $this->limit;
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
// expected legacy fields: id, name, category (topic id), folder, datum, description
$topicId = $r->category ?? ($r->topic_id ?? null);
@@ -368,16 +599,21 @@ class ForumMigrateOld extends Command
continue;
}
\App\Models\ForumAttachment::create([
'post_id' => $postId,
'file_path' => $relativePath,
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'created_at' => now(),
'updated_at' => now(),
]);
DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
\App\Models\ForumAttachment::query()->updateOrCreate(
[
'post_id' => $postId,
'file_path' => $relativePath,
],
[
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'updated_at' => now(),
]
);
}, 3);
}
}, 'id');

View File

@@ -3,9 +3,11 @@
namespace App\Console\Commands;
use App\Models\User;
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
@@ -15,9 +17,13 @@ class ImportLegacyUsers extends Command
protected array $usedUsernames = [];
protected array $usedEmails = [];
protected string $migrationLogPath;
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);
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
@@ -56,9 +62,19 @@ class ImportLegacyUsers extends Command
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId));
$baseUsername = $this->sanitizeUsername($rawLegacyUsername);
$username = $this->uniqueUsername($baseUsername);
$normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername);
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
);
}
$email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null;
@@ -88,6 +104,7 @@ class ImportLegacyUsers extends Command
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => now(),
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => $passwordHash,
@@ -126,6 +143,21 @@ class ImportLegacyUsers extends Command
'created_at' => $now,
'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,
]
);
}
}
});
}
@@ -143,19 +175,12 @@ class ImportLegacyUsers extends Command
protected function sanitizeUsername(string $username): string
{
$username = strtolower(trim($username));
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
return UsernamePolicy::sanitizeLegacy($username);
}
protected function uniqueUsername(string $base): string
{
$name = $base;
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$name = UsernamePolicy::uniqueCandidate($base);
$this->usedUsernames[$name] = $name;
return $name;
}