Auth: convert auth views and verification email to Nova layout
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user