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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user