option('dry-run'); $force = $this->option('force'); $removeLegacy = $this->option('remove-legacy'); $legacyPath = base_path($this->option('path')); $userId = $this->option('user-id') ? (int) $this->option('user-id') : null; $this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : '')); // Detect processing backend: Intervention preferred, GD fallback $useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic'); if ($useIntervention) { Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']); } $bar = null; $query = User::with('profile'); if ($userId) { $query->where('id', $userId); } $query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) { foreach ($users as $user) { /** @var UserProfile|null $profile */ $profile = $user->profile; if (!$profile) { continue; } // 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; } } // 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; } } // 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; } } }