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;
}
}
}