diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php index 45f893d..6267e3c 100644 --- a/app/Console/Commands/AvatarsMigrate.php +++ b/app/Console/Commands/AvatarsMigrate.php @@ -2,82 +2,124 @@ namespace App\Console\Commands; +use App\Services\AvatarService; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use App\Services\AvatarService; class AvatarsMigrate extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'avatars:migrate {--force}'; - - /** - * The console command description. - * - * @var string - */ - protected $description = 'Migrate legacy avatars to new WebP avatar storage'; - - protected $service; - - public function __construct(AvatarService $service) + protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}'; + + protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata'; + + public function __construct(private readonly AvatarService $service) { parent::__construct(); - $this->service = $service; } - public function handle() + public function handle(): int { + $force = (bool) $this->option('force'); + $limit = max(0, (int) $this->option('limit')); + $this->info('Starting avatar migration...'); - // Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon - $rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get(); + $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()) { - // fallback to users table - $rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get(); + $this->info('No avatars require migration.'); + + return self::SUCCESS; } - $count = 0; + $migrated = 0; + $skipped = 0; + $failed = 0; + foreach ($rows as $row) { - $userId = $row->user_id; - $legacy = $row->avatar_legacy ?? null; - if (!$legacy) { + $userId = (int) $row->user_id; + $legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon); + + 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 common legacy paths - $candidates = [ - public_path('user-picture/' . $legacy), - public_path('avatar/' . $userId . '/' . $legacy), - storage_path('app/public/user-picture/' . $legacy), - storage_path('app/public/avatar/' . $userId . '/' . $legacy), - ]; - - $found = false; - foreach ($candidates as $p) { - if (file_exists($p) && is_readable($p)) { - $this->info("Processing user {$userId} from {$p}"); - $hash = $this->service->storeFromLegacyFile($userId, $p); - if ($hash) { - $this->info(" -> migrated, hash={$hash}"); - $count++; - $found = true; - break; - } + try { + $hash = $this->service->storeFromLegacyFile($userId, $path); + if (!$hash) { + $failed++; + $this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})"); + continue; } + + $migrated++; + $this->line("User {$userId}: migrated ({$hash})"); + } catch (\Throwable $e) { + $failed++; + $this->warn("User {$userId}: migration failed ({$e->getMessage()})"); } + } + + $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; + } + + $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), + ]; - if (!$found) { - $this->warn("Legacy file not found for user {$userId}, filename={$legacy}"); + foreach ($candidates as $candidate) { + if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) { + return $candidate; } } - $this->info("Migration complete. Processed: {$count}"); - return 0; + return null; } } diff --git a/app/Http/Controllers/AvatarController.php b/app/Http/Controllers/AvatarController.php index b80150d..f6c1c23 100644 --- a/app/Http/Controllers/AvatarController.php +++ b/app/Http/Controllers/AvatarController.php @@ -2,10 +2,9 @@ namespace App\Http\Controllers; +use App\Http\Requests\AvatarUploadRequest; use App\Services\AvatarService; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Validator; +use Illuminate\Http\JsonResponse; class AvatarController { @@ -19,22 +18,13 @@ public function __construct(AvatarService $service) /** * Handle avatar upload request. */ - public function upload(Request $request) + public function upload(AvatarUploadRequest $request): JsonResponse { - $user = Auth::user(); + $user = $request->user(); if (!$user) { return response()->json(['error' => 'Unauthorized'], 401); } - $rules = [ - 'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp', - ]; - - $validator = Validator::make($request->all(), $rules); - if ($validator->fails()) { - return response()->json(['errors' => $validator->errors()], 422); - } - $file = $request->file('avatar'); try { diff --git a/app/Http/Controllers/Legacy/AvatarController.php b/app/Http/Controllers/Legacy/AvatarController.php index eb322a0..391c514 100644 --- a/app/Http/Controllers/Legacy/AvatarController.php +++ b/app/Http/Controllers/Legacy/AvatarController.php @@ -4,64 +4,15 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; +use App\Support\AvatarUrl; class AvatarController extends Controller { public function show(Request $request, $id, $name = null) { - $user_id = (int) $id; + $userId = (int) $id; + $target = AvatarUrl::forUser($userId, null, 128); - // default avatar in project public gfx - $defaultAvatar = public_path('gfx/avatar.jpg'); - - try { - $icon = DB::table('users')->where('user_id', $user_id)->value('icon'); - } catch (\Throwable $e) { - $icon = null; - } - - $candidates = []; - if (!empty($icon)) { - // common legacy locations to check - $candidates[] = base_path('oldSite/www/files/usericons/' . $icon); - $candidates[] = base_path('oldSite/www/files/usericons/' . rawurlencode($icon)); - $candidates[] = base_path('oldSite/www/files/usericons/' . basename($icon)); - $candidates[] = public_path('avatar/' . $user_id . '/' . $icon); - $candidates[] = public_path('avatar/' . $user_id . '/' . basename($icon)); - $candidates[] = storage_path('app/public/usericons/' . $icon); - $candidates[] = storage_path('app/public/usericons/' . basename($icon)); - } - - // find first readable file - $found = null; - foreach ($candidates as $path) { - if ($path && file_exists($path) && is_readable($path)) { - $found = $path; - break; - } - } - - if ($found) { - $type = @exif_imagetype($found); - if ($type) { - $mime = image_type_to_mime_type($type); - } else { - $f = finfo_open(FILEINFO_MIME_TYPE); - $mime = finfo_file($f, $found) ?: 'application/octet-stream'; - finfo_close($f); - } - - return response()->file($found, ['Content-Type' => $mime]); - } - - // fallback to default - if (file_exists($defaultAvatar) && is_readable($defaultAvatar)) { - return response()->file($defaultAvatar, ['Content-Type' => 'image/jpeg']); - } - - // final fallback: 404 - abort(404); + return redirect()->away($target, 301); } } diff --git a/app/Http/Controllers/Legacy/BuddiesController.php b/app/Http/Controllers/Legacy/BuddiesController.php index cfb3b04..d1ec9cc 100644 --- a/app/Http/Controllers/Legacy/BuddiesController.php +++ b/app/Http/Controllers/Legacy/BuddiesController.php @@ -22,7 +22,7 @@ public function index(Request $request) ->leftJoin('users as t2', 't1.user_id', '=', 't2.id') ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') ->where('t1.friend_id', $user->id) - ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added') + ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added') ->orderByDesc('t1.date_added'); $followers = $query->paginate($perPage)->withQueryString(); diff --git a/app/Http/Controllers/Legacy/LatestCommentsController.php b/app/Http/Controllers/Legacy/LatestCommentsController.php index eb091e2..02db175 100644 --- a/app/Http/Controllers/Legacy/LatestCommentsController.php +++ b/app/Http/Controllers/Legacy/LatestCommentsController.php @@ -7,6 +7,7 @@ use App\Models\ArtworkComment; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; class LatestCommentsController extends Controller { @@ -36,7 +37,7 @@ public function index(Request $request) 'comment_description' => $c->content, 'commenter_id' => $c->user_id, 'country' => $user->country ?? null, - 'icon' => $user->avatar ?? null, + 'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null, 'uname' => $user->username ?? $user->name ?? 'User', 'signature' => $user->signature ?? null, 'user_type' => $user->role ?? null, diff --git a/app/Http/Controllers/Legacy/MyBuddiesController.php b/app/Http/Controllers/Legacy/MyBuddiesController.php index 3ff2f96..9ab8db4 100644 --- a/app/Http/Controllers/Legacy/MyBuddiesController.php +++ b/app/Http/Controllers/Legacy/MyBuddiesController.php @@ -23,7 +23,7 @@ public function index(Request $request) ->leftJoin('users as t2', 't1.friend_id', '=', 't2.id') ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') ->where('t1.user_id', $user->id) - ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added') + ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added') ->orderByDesc('t1.date_added'); $buddies = $query->paginate($perPage)->withQueryString(); diff --git a/app/Http/Controllers/Legacy/ProfileController.php b/app/Http/Controllers/Legacy/ProfileController.php index dfe911c..5d3b149 100644 --- a/app/Http/Controllers/Legacy/ProfileController.php +++ b/app/Http/Controllers/Legacy/ProfileController.php @@ -10,6 +10,7 @@ use App\Models\Artwork; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; class ProfileController extends Controller { @@ -63,7 +64,7 @@ public function show(Request $request, ?int $id = null, ?string $slug = null) 'user_id' => $user->id, 'uname' => $user->name, 'real_name' => $user->name, - 'icon' => $user->avatar ?? null, + 'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'), 'about_me' => $user->bio ?? null, ]; diff --git a/app/Http/Controllers/Legacy/UserController.php b/app/Http/Controllers/Legacy/UserController.php index a8a3546..e7286a8 100644 --- a/app/Http/Controllers/Legacy/UserController.php +++ b/app/Http/Controllers/Legacy/UserController.php @@ -4,11 +4,10 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use App\Models\User; +use App\Services\AvatarService; use Carbon\Carbon; class UserController extends Controller @@ -72,12 +71,12 @@ public function index(Request $request) // Files: avatar/photo/emoticon if ($request->hasFile('avatar')) { - $f = $request->file('avatar'); - $name = $user->id . '.' . $f->getClientOriginalExtension(); - $f->move(public_path('avatar'), $name); - // store filename in profile avatar (legacy field) ÔÇö modern avatar pipeline will later migrate - $profileUpdates['avatar'] = $name; - $user->icon = $name; + try { + $hash = app(AvatarService::class)->storeFromUploadedFile((int) $user->id, $request->file('avatar')); + $user->icon = $hash; + } catch (\Throwable $e) { + $request->session()->flash('error', 'Avatar upload failed.'); + } } if ($request->hasFile('personal_picture')) { @@ -141,7 +140,7 @@ public function index(Request $request) if (isset($profile->birthdate)) $user->birth = $profile->birthdate; if (isset($profile->gender)) $user->gender = $profile->gender; if (isset($profile->country_code)) $user->country_code = $profile->country_code; - if (isset($profile->avatar)) $user->icon = $profile->avatar; + if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash; if (isset($profile->cover_image)) $user->picture = $profile->cover_image; if (isset($profile->signature)) $user->signature = $profile->signature; if (isset($profile->description)) $user->description = $profile->description; diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index d2d80cb..542b27d 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -95,11 +95,7 @@ public function update(ProfileUpdateRequest $request, \App\Services\AvatarServic // Files: avatar -> use AvatarService, emoticon and photo -> store to public disk if ($request->hasFile('avatar')) { try { - $hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); - // store returned hash into profile avatar column - if (!empty($hash)) { - $profileUpdates['avatar'] = $hash; - } + $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); } catch (\Exception $e) { return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage()); } diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 588d90d..d481540 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -37,7 +37,7 @@ public function rules(): array 'about' => ['nullable', 'string'], 'signature' => ['nullable', 'string'], 'description' => ['nullable', 'string'], - 'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], + 'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'], 'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], 'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], ]; diff --git a/app/Models/User.php b/app/Models/User.php index fc76f6c..d679929 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -55,6 +56,11 @@ public function artworks(): HasMany return $this->hasMany(Artwork::class); } + public function profile(): HasOne + { + return $this->hasOne(UserProfile::class, 'user_id'); + } + public function hasRole(string $role): bool { return strtolower((string) ($this->role ?? '')) === strtolower($role); diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php index 46c06ce..7bb3de6 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -4,7 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Facades\Storage; +use App\Support\AvatarUrl; class UserProfile extends Model { @@ -18,7 +18,7 @@ class UserProfile extends Model 'about', 'signature', 'description', - 'avatar', + 'avatar_legacy', 'avatar_hash', 'avatar_mime', 'avatar_updated_at', @@ -43,27 +43,12 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } - /** - * Return a public URL for the avatar when stored on the `public` disk under `avatars/`. - */ public function getAvatarUrlAttribute(): ?string { - if (empty($this->avatar)) { + if (empty($this->user_id)) { return null; } - // If the stored value already looks like a full URL, return it. - if (preg_match('#^https?://#i', $this->avatar)) { - return $this->avatar; - } - - // Prefer `public` disk and avatars folder. - $path = 'avatars/' . ltrim($this->avatar, '/'); - if (Storage::disk('public')->exists($path)) { - return Storage::disk('public')->url($path); - } - - // Fallback: return null if not found - return null; + return AvatarUrl::forUser((int) $this->user_id, $this->avatar_hash, 128); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1155fb1..8a4f9ed 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,7 +35,7 @@ public function boot(): void // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { $uploadCount = $favCount = $msgCount = $noticeCount = 0; - $avatar = null; + $avatarHash = null; $displayName = null; $userId = null; @@ -72,15 +72,15 @@ public function boot(): void try { $profile = DB::table('user_profiles')->where('user_id', $userId)->first(); - $avatar = $profile->avatar ?? null; + $avatarHash = $profile->avatar_hash ?? null; } catch (\Throwable $e) { - $avatar = null; + $avatarHash = null; } $displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); } - $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName')); + $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName')); }); } diff --git a/app/Services/AvatarService.php b/app/Services/AvatarService.php index 1430ab9..2fb38dd 100644 --- a/app/Services/AvatarService.php +++ b/app/Services/AvatarService.php @@ -2,15 +2,22 @@ namespace App\Services; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Facades\DB; -use Intervention\Image\ImageManagerStatic as Image; -use RuntimeException; +use App\Models\UserProfile; use Carbon\Carbon; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Intervention\Image\Drivers\Gd\Driver as GdDriver; +use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; +use Intervention\Image\Encoders\WebpEncoder; +use Intervention\Image\ImageManager; +use RuntimeException; class AvatarService { + private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; + + private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + protected $sizes = [ 'xs' => 32, 'sm' => 64, @@ -21,149 +28,160 @@ class AvatarService protected $quality = 85; + private ?ImageManager $manager = null; + public function __construct() { - // Guard: if Intervention Image is not installed, defer error until actual use - if (class_exists(\Intervention\Image\ImageManagerStatic::class)) { - try { - Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']); - $this->imageAvailable = true; - } catch (\Throwable $e) { - // If configuration fails, treat as unavailable and log for diagnostics - logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage()); - $this->imageAvailable = false; - } - } else { - $this->imageAvailable = false; + $configuredSizes = array_values(array_filter((array) config('avatars.sizes', [32, 64, 128, 256, 512]), static fn ($size) => (int) $size > 0)); + if ($configuredSizes !== []) { + $this->sizes = array_fill_keys(array_map('strval', $configuredSizes), null); + $this->sizes = array_combine(array_keys($this->sizes), $configuredSizes); } - } - /** - * Process an uploaded file for a user and store webp sizes. - * Returns the computed sha1 hash. - * - * @param int $userId - * @param UploadedFile $file - * @return string sha1 hash - */ - public function storeFromUploadedFile(int $userId, UploadedFile $file): string - { - if (! $this->imageAvailable) { - throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.'); - } + $this->quality = (int) config('avatars.quality', 85); - // Load image and re-encode to webp after validating try { - $img = Image::make($file->getRealPath()); + $this->manager = extension_loaded('gd') + ? new ImageManager(new GdDriver()) + : new ImageManager(new ImagickDriver()); } catch (\Throwable $e) { - throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage()); - } - - // Ensure square center crop per spec - $max = max($img->width(), $img->height()); - $img->fit($max, $max); - - $basePath = "avatars/{$userId}"; - Storage::disk('public')->makeDirectory($basePath); - - // Save original as webp - $originalData = (string) $img->encode('webp', $this->quality); - Storage::disk('public')->put($basePath . '/original.webp', $originalData); - - // Generate sizes - foreach ($this->sizes as $name => $size) { - $resized = $img->resize($size, $size, function ($constraint) { - $constraint->upsize(); - })->encode('webp', $this->quality); - Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized); + logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage()); + $this->manager = null; } + } - $hash = sha1($originalData); - $mime = 'image/webp'; + public function storeFromUploadedFile(int $userId, UploadedFile $file): string + { + $this->assertImageManagerAvailable(); + $this->assertStorageIsAllowed(); + $this->assertSecureImageUpload($file); - // Persist metadata to user_profiles if exists, otherwise users table fallbacks - if (SchemaHasTable('user_profiles')) { - DB::table('user_profiles')->where('user_id', $userId)->update([ - 'avatar_hash' => $hash, - 'avatar_updated_at' => Carbon::now(), - 'avatar_mime' => $mime, - ]); - } else { - DB::table('users')->where('id', $userId)->update([ - 'avatar_hash' => $hash, - 'avatar_updated_at' => Carbon::now(), - 'avatar_mime' => $mime, - ]); + $binary = file_get_contents($file->getRealPath()); + if ($binary === false || $binary === '') { + throw new RuntimeException('Uploaded avatar file is empty or unreadable.'); } - return $hash; + return $this->storeFromBinary($userId, $binary); } - /** - * Process a legacy file path for a user (path-to-file). - * Returns sha1 or null when missing. - * - * @param int $userId - * @param string $path Absolute filesystem path - * @return string|null - */ public function storeFromLegacyFile(int $userId, string $path): ?string { + $this->assertImageManagerAvailable(); + $this->assertStorageIsAllowed(); + if (!file_exists($path) || !is_readable($path)) { return null; } - try { - $img = Image::make($path); - } catch (\Exception $e) { + $binary = file_get_contents($path); + if ($binary === false || $binary === '') { return null; } - $max = max($img->width(), $img->height()); - $img->fit($max, $max); + return $this->storeFromBinary($userId, $binary); + } + + private function storeFromBinary(int $userId, string $binary): string + { + $image = $this->readImageFromBinary($binary); + $diskName = (string) config('avatars.disk', 'public'); + $disk = Storage::disk($diskName); $basePath = "avatars/{$userId}"; - Storage::disk('public')->makeDirectory($basePath); - $originalData = (string) $img->encode('webp', $this->quality); - Storage::disk('public')->put($basePath . '/original.webp', $originalData); + $hashSeed = ''; + foreach ($this->sizes as $size) { + $variant = $image->cover($size, $size); + $encoded = (string) $variant->encode(new WebpEncoder($this->quality)); + $disk->put("{$basePath}/{$size}.webp", $encoded, [ + 'visibility' => 'public', + 'CacheControl' => 'public, max-age=31536000, immutable', + 'ContentType' => 'image/webp', + ]); - foreach ($this->sizes as $name => $size) { - $resized = $img->resize($size, $size, function ($constraint) { - $constraint->upsize(); - })->encode('webp', $this->quality); - Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized); + if ($size === 128) { + $hashSeed = $encoded; + } } - $hash = sha1($originalData); - $mime = 'image/webp'; + if ($hashSeed === '') { + throw new RuntimeException('Avatar processing failed to generate a hash seed.'); + } - if (SchemaHasTable('user_profiles')) { - DB::table('user_profiles')->where('user_id', $userId)->update([ - 'avatar_hash' => $hash, - 'avatar_updated_at' => Carbon::now(), - 'avatar_mime' => $mime, - ]); - } else { - DB::table('users')->where('id', $userId)->update([ + $hash = hash('sha256', $hashSeed); + $this->updateProfileMetadata($userId, $hash); + + return $hash; + } + + private function readImageFromBinary(string $binary) + { + try { + return $this->manager->read($binary); + } catch (\Throwable $e) { + throw new RuntimeException('Failed to decode uploaded image.'); + } + } + + private function updateProfileMetadata(int $userId, string $hash): void + { + UserProfile::query()->updateOrCreate( + ['user_id' => $userId], + [ 'avatar_hash' => $hash, + 'avatar_mime' => 'image/webp', 'avatar_updated_at' => Carbon::now(), - 'avatar_mime' => $mime, - ]); + ] + ); + } + + private function assertImageManagerAvailable(): void + { + if ($this->manager !== null) { + return; } - return $hash; + throw new RuntimeException('Avatar image processing is not available on this environment.'); } -} -/** - * Helper: check for table existence without importing Schema facade repeatedly - */ -function SchemaHasTable(string $name): bool -{ - try { - return \Illuminate\Support\Facades\Schema::hasTable($name); - } catch (\Throwable $e) { - return false; + private function assertStorageIsAllowed(): void + { + if (!app()->environment('production')) { + return; + } + + $diskName = (string) config('avatars.disk', 's3'); + if (in_array($diskName, ['local', 'public'], true)) { + throw new RuntimeException('Production avatar storage must use object storage, not local/public disks.'); + } + } + + private function assertSecureImageUpload(UploadedFile $file): void + { + $extension = strtolower((string) $file->getClientOriginalExtension()); + if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + throw new RuntimeException('Unsupported avatar file extension.'); + } + + $detectedMime = (string) $file->getMimeType(); + if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) { + throw new RuntimeException('Unsupported avatar MIME type.'); + } + + $binary = file_get_contents($file->getRealPath()); + if ($binary === false || $binary === '') { + throw new RuntimeException('Unable to read uploaded avatar data.'); + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $finfoMime = (string) $finfo->buffer($binary); + if (!in_array($finfoMime, self::ALLOWED_MIME_TYPES, true)) { + throw new RuntimeException('Avatar content did not match allowed image MIME types.'); + } + + $dimensions = @getimagesizefromstring($binary); + if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) { + throw new RuntimeException('Uploaded avatar is not a valid image.'); + } } } diff --git a/config/cdn.php b/config/cdn.php index b1b1cd0..9c70607 100644 --- a/config/cdn.php +++ b/config/cdn.php @@ -4,4 +4,5 @@ return [ 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), + 'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'), ]; diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php index 0b90950..bae1a02 100644 --- a/resources/views/components/avatar.blade.php +++ b/resources/views/components/avatar.blade.php @@ -3,9 +3,7 @@ $size = $size ?? 128; $profile = $user->profile ?? null; $hash = $profile->avatar_hash ?? null; - $src = $hash - ? asset("storage/avatars/{$user->id}/{$size}.webp?v={$hash}") - : asset('img/default-avatar.webp'); + $src = \App\Support\AvatarUrl::forUser((int) $user->id, $hash, (int) $size); $alt = $alt ?? ($user->username ?? 'avatar'); $class = $class ?? 'rounded-full'; @endphp diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php index 3833458..77b3c55 100644 --- a/resources/views/layouts/nova/toolbar.blade.php +++ b/resources/views/layouts/nova/toolbar.blade.php @@ -121,7 +121,7 @@ class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-
- Avatar + Avatar

{{ $artwork->name }}

By {{ $artwork->uname }}


@@ -30,7 +30,7 @@
@@ -53,7 +53,7 @@
- +
{{ auth()->user()->name }} diff --git a/resources/views/legacy/buddies.blade.php b/resources/views/legacy/buddies.blade.php index 1d91897..6b15b25 100644 --- a/resources/views/legacy/buddies.blade.php +++ b/resources/views/legacy/buddies.blade.php @@ -27,7 +27,7 @@
diff --git a/resources/views/legacy/forum/posts.blade.php b/resources/views/legacy/forum/posts.blade.php index d497a79..28e9f66 100644 --- a/resources/views/legacy/forum/posts.blade.php +++ b/resources/views/legacy/forum/posts.blade.php @@ -28,7 +28,7 @@
@if (!empty($post->user_id) && !empty($post->icon)) - {{ $post->uname }} + {{ $post->uname }} @else
@endif diff --git a/resources/views/legacy/gallery.blade.php b/resources/views/legacy/gallery.blade.php index 7c4c7c3..327e583 100644 --- a/resources/views/legacy/gallery.blade.php +++ b/resources/views/legacy/gallery.blade.php @@ -34,7 +34,7 @@
User
- {{ $user->uname ?? $user->name }} + {{ $user->uname ?? $user->name }}

{{ $user->uname ?? $user->name }}

{{ $user->about_me ?? '' }}

diff --git a/resources/views/legacy/interview.blade.php b/resources/views/legacy/interview.blade.php index af1364f..2bb59b6 100644 --- a/resources/views/legacy/interview.blade.php +++ b/resources/views/legacy/interview.blade.php @@ -32,7 +32,7 @@ @if(!empty($comment->user_id) && !empty($comment->icon)) -
+
@endif
Posted by: {{ $comment->author }}
Posts: {{ $postCounts[$comment->author] ?? 0 }} diff --git a/resources/views/legacy/interviews.blade.php b/resources/views/legacy/interviews.blade.php index a9650a4..b78d76a 100644 --- a/resources/views/legacy/interviews.blade.php +++ b/resources/views/legacy/interviews.blade.php @@ -22,7 +22,7 @@ @if(!empty($interview->icon)) - + @else diff --git a/resources/views/legacy/latest-comments.blade.php b/resources/views/legacy/latest-comments.blade.php index ed99954..2e33899 100644 --- a/resources/views/legacy/latest-comments.blade.php +++ b/resources/views/legacy/latest-comments.blade.php @@ -15,7 +15,7 @@
diff --git a/resources/views/legacy/monthly-commentators.blade.php b/resources/views/legacy/monthly-commentators.blade.php index 28b7921..7066d11 100644 --- a/resources/views/legacy/monthly-commentators.blade.php +++ b/resources/views/legacy/monthly-commentators.blade.php @@ -27,7 +27,7 @@ - {{ $row->uname }} + {{ $row->uname }} diff --git a/resources/views/legacy/mybuddies.blade.php b/resources/views/legacy/mybuddies.blade.php index 13fd2c6..c91f446 100644 --- a/resources/views/legacy/mybuddies.blade.php +++ b/resources/views/legacy/mybuddies.blade.php @@ -28,7 +28,7 @@ diff --git a/resources/views/legacy/news.blade.php b/resources/views/legacy/news.blade.php index 7c87583..0d87945 100644 --- a/resources/views/legacy/news.blade.php +++ b/resources/views/legacy/news.blade.php @@ -36,7 +36,7 @@ @if(!empty($ar->icon)) @endif diff --git a/resources/views/legacy/profile.blade.php b/resources/views/legacy/profile.blade.php index 87239b6..f3e9453 100644 --- a/resources/views/legacy/profile.blade.php +++ b/resources/views/legacy/profile.blade.php @@ -34,7 +34,7 @@
User
- {{ $user->uname }} + {{ $user->uname }}

{{ $user->uname }}

{{ $user->about_me ?? '' }}

diff --git a/resources/views/legacy/received-comments.blade.php b/resources/views/legacy/received-comments.blade.php index 75f08fd..855bd95 100644 --- a/resources/views/legacy/received-comments.blade.php +++ b/resources/views/legacy/received-comments.blade.php @@ -21,7 +21,7 @@
diff --git a/resources/views/legacy/toolbar.blade.php b/resources/views/legacy/toolbar.blade.php index 5dcbf97..0cefc74 100644 --- a/resources/views/legacy/toolbar.blade.php +++ b/resources/views/legacy/toolbar.blade.php @@ -119,9 +119,9 @@ } try { $profile = \Illuminate\Support\Facades\DB::table('user_profiles')->where('user_id', $userId)->first(); - $avatar = $profile->avatar ?? null; + $avatarHash = $profile->avatar_hash ?? null; } catch (\Throwable $e) { - $avatar = null; + $avatarHash = null; } $displayName = auth()->user()->name ?: (auth()->user()->username ?? ''); @endphp @@ -141,9 +141,7 @@