Files
SkinbaseNova/avatar_patch.diff
2026-02-17 17:14:43 +01:00

1186 lines
53 KiB
Diff

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-
<div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
- src="{{ url('/avatar/' . ($userId ?? Auth::id() ?? 0) . '/' . ($avatar ? rawurlencode(basename($avatar)) : '1.png')) }}"
+ src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? Auth::id() ?? 0), $avatarHash ?? null, 64) }}"
alt="{{ $displayName ?? 'User' }}" />
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
diff --git a/resources/views/legacy/art.blade.php b/resources/views/legacy/art.blade.php
index 5852dd8..71bf8d5 100644
--- a/resources/views/legacy/art.blade.php
+++ b/resources/views/legacy/art.blade.php
@@ -11,7 +11,7 @@
</div>
<div style="clear:both;margin-top:10px;">
- <img src="/avatar/{{ $artwork->user_id ?? 0 }}/{{ urlencode($artwork->icon ?? '') }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), null, 50) }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar">
<h1 class="page-header">{{ $artwork->name }}</h1>
<p>By <i class="fa fa-user fa-fw"></i> <a href="/profile/{{ $artwork->user_id }}/{{ \Illuminate\Support\Str::slug($artwork->uname) }}" title="Profile of member {{ $artwork->uname }}">{{ $artwork->uname }}</a></p>
<hr>
@@ -30,7 +30,7 @@
<div class="comment_box effect3">
<div class="cb_image">
<a href="/profile/{{ $comment->user_id }}/{{ urlencode($comment->uname) }}">
- <img src="/avatar/{{ $comment->user_id }}/{{ urlencode($comment->icon) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
</a>
</div>
<div class="bubble_comment panel panel-skinbase">
@@ -53,7 +53,7 @@
<div class="comment_box effect3">
<div class="cb_image">
<a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">
- <img src="/avatar/{{ auth()->id() }}/{{ urlencode(auth()->user()->avatar ?? '') }}" class="comment_avatar" width="50" height="50">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) auth()->id(), null, 50) }}" class="comment_avatar" width="50" height="50">
</a>
<br>
<a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">{{ auth()->user()->name }}</a>
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 @@
<div>
<a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}">
- <img src="/avatar/{{ $followerId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $followerId, null, 50) }}" alt="{{ $uname }}">
</a>
</div>
</div>
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 @@
<div class="panel-body" style="display:flex; gap:12px;">
<div style="min-width:52px;">
@if (!empty($post->user_id) && !empty($post->icon))
- <img src="/avatar/{{ $post->user_id }}/{{ $post->icon }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
@else
<div class="img-thumbnail" style="width:50px;height:50px;"></div>
@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 @@
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>User</strong></div>
<div class="panel-body">
- <img src="/avatar/{{ (int)($user->user_id ?? $user->id) }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) ($user->user_id ?? $user->id), null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
<h3>{{ $user->uname ?? $user->name }}</h3>
<p>{{ $user->about_me ?? '' }}</p>
</div>
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 @@
<tr>
<td rowspan="3" valign="top" width="100" style="background:#fff">
@if(!empty($comment->user_id) && !empty($comment->icon))
- <div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="/avatar/{{ $comment->user_id }}/{{ \Illuminate\Support\Str::slug($comment->author ?? '') }}" width="50" height="50" border="0" alt="" /></a></div>
+ <div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" border="0" alt="" /></a></div>
@endif
<br/>Posted by: <b><a href="/profile/{{ $comment->user_id ?? '' }}">{{ $comment->author }}</a></b><br/>
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 @@
<td style="width:60px;">
@if(!empty($interview->icon))
<a href="/profile/{{ $interview->user_id }}/{{ \Illuminate\Support\Str::slug($interview->uname ?? '') }}">
- <img src="/avatar/{{ $interview->user_id }}/{{ $interview->icon }}" width="50" height="50" alt="">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $interview->user_id, null, 50) }}" width="50" height="50" alt="">
</a>
@else
<img src="/gfx/avatar.jpg" alt="">
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 @@
<div class="comment_box effect3">
<div class="cb_image">
<a href="/profile/{{ $comment->commenter_id }}/{{ rawurlencode($comment->uname) }}">
- <img src="/avatar/{{ (int)$comment->commenter_id }}/{{ rawurlencode($comment->uname) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->commenter_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
</a>
</div>
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 @@
<tr>
<td width="50" class="text-center">
<a href="/profile/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}">
- <img src="/avatar/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}" width="30" alt="{{ $row->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $row->user_id, null, 32) }}" width="30" alt="{{ $row->uname }}">
</a>
</td>
<td>
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 @@
<div>
<a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}">
- <img src="/avatar/{{ $friendId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
</a>
</div>
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))
<div style="float:right;padding-left:20px;">
<a href="/profile/{{ $ar->user_id ?? '' }}/{{ Str::slug($ar->uname ?? '') }}">
- <img src="/avatar/{{ $ar->user_id ?? '' }}/{{ $ar->icon ?? '' }}" width="50" height="50" alt="">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) ($ar->user_id ?? 0), null, 50) }}" width="50" height="50" alt="">
</a>
</div>
@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 @@
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>User</strong></div>
<div class="panel-body">
- <img src="/avatar/{{ (int)$user->user_id }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $user->user_id, null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
<h3>{{ $user->uname }}</h3>
<p>{{ $user->about_me ?? '' }}</p>
</div>
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 @@
<div class="comment_box effect3">
<div class="cb_image">
<a href="/profile/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->name ?? $author->uname ?? '') }}">
- <img src="/avatar/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->icon ?? '') }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) ($author->id ?? $author->user_id), null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}">
</a>
</div>
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 @@
<li class="dropdown">
<a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
- @if($avatar)
- <img src="/storage/{{ ltrim($avatar, '/') }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
- @endif
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $userId, $avatarHash ?? null, 32) }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
<span class="username">{{ $displayName }}</span>
&nbsp;<i class="fa fa-angle-down"></i>
</a>
diff --git a/resources/views/legacy/top-authors.blade.php b/resources/views/legacy/top-authors.blade.php
index febb459..ed4513b 100644
--- a/resources/views/legacy/top-authors.blade.php
+++ b/resources/views/legacy/top-authors.blade.php
@@ -27,7 +27,7 @@
<tr>
<td width="80">
<a href="/profile/{{ $u->user_id }}/{{ \Illuminate\Support\Str::slug($u->uname) }}">
- <img src="/avatar/{{ $u->user_id }}/{{ rawurlencode($u->icon ?? '') }}" width="30" height="30" alt="{{ $u->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $u->user_id, null, 32) }}" width="30" height="30" alt="{{ $u->uname }}">
</a>
</td>
<td>
@@ -60,7 +60,7 @@
<tr>
<td width="60" align="center">
<a href="/profile/{{ $f->user_id }}/{{ \Illuminate\Support\Str::slug($f->uname) }}">
- <img src="/avatar/{{ $f->user_id }}/{{ rawurlencode($f->uname) }}" width="30" alt="{{ $f->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $f->user_id, null, 32) }}" width="30" alt="{{ $f->uname }}">
</a>
</td>
<td>
@@ -92,7 +92,7 @@
<tr>
<td width="50" align="center">
<a href="/profile/{{ $c->user_id }}/{{ \Illuminate\Support\Str::slug($c->uname) }}">
- <img src="/avatar/{{ $c->user_id }}/{{ rawurlencode($c->uname) }}" width="30" alt="{{ $c->uname }}">
+ <img src="{{ \App\Support\AvatarUrl::forUser((int) $c->user_id, null, 32) }}" width="30" alt="{{ $c->uname }}">
</a>
</td>
<td>
diff --git a/tests/Feature/AvatarUploadTest.php b/tests/Feature/AvatarUploadTest.php
index aafbf67..3928cbf 100644
--- a/tests/Feature/AvatarUploadTest.php
+++ b/tests/Feature/AvatarUploadTest.php
@@ -1,15 +1,64 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use App\Models\User;
use Tests\TestCase;
class AvatarUploadTest extends TestCase
{
use RefreshDatabase;
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ config()->set('avatars.disk', 'public');
+ Storage::fake('public');
+ }
+
public function test_upload_requires_authentication()
{
$response = $this->postJson(route('avatar.upload'));
$response->assertStatus(401);
}
+
+ public function test_upload_rejects_invalid_avatar_type(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->postJson(route('avatar.upload'), [
+ 'avatar' => UploadedFile::fake()->create('avatar.txt', 50, 'text/plain'),
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_upload_processes_avatar_and_stores_webp_variants(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->postJson(route('avatar.upload'), [
+ 'avatar' => UploadedFile::fake()->image('avatar.png', 300, 180),
+ ]);
+
+ $response->assertOk()->assertJson(['success' => true]);
+
+ $payload = $response->json();
+ $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) ($payload['hash'] ?? ''));
+
+ $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/32.webp"));
+ $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/64.webp"));
+ $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/128.webp"));
+ $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/256.webp"));
+ $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/512.webp"));
+
+ $record = DB::table('user_profiles')->where('user_id', $user->id)->first();
+ $this->assertNotNull($record);
+ $this->assertSame('image/webp', $record->avatar_mime);
+ $this->assertSame($payload['hash'], $record->avatar_hash);
+ $this->assertNotNull($record->avatar_updated_at);
+ }
}