310 lines
10 KiB
PHP
310 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
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
|
|
{
|
|
/**
|
|
* 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}
|
|
';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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' . ($dry ? ' (dry-run)' : ''));
|
|
|
|
// 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;
|
|
|
|
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 (!$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;
|
|
}
|
|
}
|
|
}
|
|
|