From 41287914aa983d2231bf8820222e14e043bc6a49 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Tue, 17 Feb 2026 17:14:43 +0100 Subject: [PATCH] Upload beautify --- app/Console/Commands/AvatarsMigrate.php | 142 +- app/Http/Controllers/AvatarController.php | 47 - .../Controllers/Community/ChatController.php | 44 + .../Controllers/Community/ForumController.php | 38 + .../Community/InterviewController.php | 103 ++ .../Community/LatestCommentsController.php | 54 + .../Community/LatestController.php | 47 + .../Controllers/Community/NewsController.php | 44 + .../{ => Dashboard}/ManageController.php | 24 +- app/Http/Controllers/InterviewController.php | 103 ++ app/Http/Controllers/InterviewsController.php | 28 + .../Controllers/Legacy/AvatarController.php | 57 +- .../Controllers/Legacy/BuddiesController.php | 2 +- .../Legacy/LatestCommentsController.php | 3 +- .../Legacy/MyBuddiesController.php | 2 +- .../Controllers/Legacy/ProfileController.php | 3 +- .../Controllers/Legacy/UserController.php | 17 +- .../Controllers/Misc/AvatarController.php | 55 + .../Controllers/PhotographyController.php | 101 ++ .../Controllers/User/AvatarController.php | 59 + .../Controllers/User/BuddiesController.php | 37 + .../Controllers/User/FavouritesController.php | 143 ++ .../Controllers/User/MembersController.php | 49 + .../User/MonthlyCommentatorsController.php | 39 + .../Controllers/User/MyBuddiesController.php | 56 + .../{ => User}/ProfileController.php | 36 +- .../User/ReceivedCommentsController.php | 27 + .../Controllers/User/StatisticsController.php | 66 + .../User/TodayDownloadsController.php | 64 + .../User/TodayInHistoryController.php | 53 + .../Controllers/User/TopAuthorsController.php | 57 + .../User/TopFavouritesController.php | 56 + app/Http/Controllers/User/UserController.php | 27 + app/Http/Controllers/Web/ArtController.php | 61 + .../{ => Web}/BrowseCategoriesController.php | 6 +- .../{ => Web}/BrowseGalleryController.php | 7 +- .../Controllers/Web/CategoryController.php | 100 ++ .../Web/DailyUploadsController.php | 94 ++ .../Web/FeaturedArtworksController.php | 62 + .../Controllers/Web/GalleryController.php | 41 + app/Http/Controllers/Web/HomeController.php | 80 ++ app/Http/Requests/AvatarUploadRequest.php | 27 + .../Manage/ManageArtworkUpdateRequest.php | 2 +- app/Http/Requests/ProfileUpdateRequest.php | 2 +- app/Models/User.php | 6 + app/Models/UserProfile.php | 23 +- app/Providers/AppServiceProvider.php | 8 +- app/Services/AvatarService.php | 326 +++-- app/Support/AvatarUrl.php | 50 + avatar_patch.diff | 1185 +++++++++++++++++ composer.json | 6 +- composer.lock | 321 ++++- config/avatars.php | 7 + config/cdn.php | 1 + ..._000100_finalize_avatar_schema_for_cdn.php | 61 + ...120500_finalize_avatar_metadata_schema.php | 61 + docs/avatar-cdn-config-notes.md | 32 + package-lock.json | 64 + package.json | 7 +- playwright.config.ts | 19 + public/gfx/sb_logo.png | Bin 0 -> 14045 bytes public/gfx/sb_logo2.png | Bin 0 -> 10611 bytes public/gfx/sb_logo_white.png | Bin 0 -> 8904 bytes resources/js/app.js | 17 + .../js/components/profile/AvatarUploader.jsx | 198 +++ .../{legacy => community}/chat.blade.php | 2 +- resources/views/components/avatar.blade.php | 4 +- resources/views/layouts/_legacy.blade.php | 50 + resources/views/layouts/legacy.blade.php | 82 -- .../views/layouts/nova/toolbar.blade.php | 421 +++--- .../views/legacy/_artwork_card.blade.php | 117 +- resources/views/legacy/forum/posts.blade.php | 2 +- resources/views/legacy/interview.blade.php | 2 +- resources/views/legacy/interviews.blade.php | 2 +- .../views/legacy/latest-comments.blade.php | 2 +- .../legacy/monthly-commentators.blade.php | 2 +- resources/views/legacy/mybuddies.blade.php | 2 +- resources/views/legacy/news.blade.php | 2 +- resources/views/legacy/profile.blade.php | 2 +- .../views/legacy/received-comments.blade.php | 2 +- resources/views/legacy/toolbar.blade.php | 8 +- resources/views/legacy/top-authors.blade.php | 6 +- resources/views/manage/edit.blade.php | 6 +- resources/views/manage/index.blade.php | 6 +- resources/views/profile/edit.blade.php | 1 + .../update-profile-information-form.blade.php | 11 + .../{legacy => shared}/placeholder.blade.php | 0 .../views/{legacy => user}/buddies.blade.php | 4 +- .../{legacy => user}/favourites.blade.php | 2 +- resources/views/{legacy => web}/art.blade.php | 8 +- .../{legacy => web}/categories.blade.php | 2 +- .../views/{legacy => web}/category.blade.php | 2 +- .../{legacy => web}/daily-uploads.blade.php | 4 +- .../featured-artworks.blade.php | 4 +- .../views/{legacy => web}/gallery.blade.php | 4 +- resources/views/web/home.blade.php | 17 + resources/views/web/home/featured.blade.php | 47 + resources/views/web/home/news.blade.php | 80 ++ resources/views/web/home/uploads.blade.php | 14 + .../web/partials/_artwork_card.blade.php | 112 ++ .../partials/daily-uploads-grid.blade.php | 0 routes/legacy.php | 98 -- routes/web.php | 107 +- test-results/.last-run.json | 4 + tests/Feature/AvatarUploadTest.php | 49 + tests/e2e/home.spec.ts | 7 + 106 files changed, 4948 insertions(+), 906 deletions(-) delete mode 100644 app/Http/Controllers/AvatarController.php create mode 100644 app/Http/Controllers/Community/ChatController.php create mode 100644 app/Http/Controllers/Community/ForumController.php create mode 100644 app/Http/Controllers/Community/InterviewController.php create mode 100644 app/Http/Controllers/Community/LatestCommentsController.php create mode 100644 app/Http/Controllers/Community/LatestController.php create mode 100644 app/Http/Controllers/Community/NewsController.php rename app/Http/Controllers/{ => Dashboard}/ManageController.php (83%) create mode 100644 app/Http/Controllers/InterviewController.php create mode 100644 app/Http/Controllers/InterviewsController.php create mode 100644 app/Http/Controllers/Misc/AvatarController.php create mode 100644 app/Http/Controllers/PhotographyController.php create mode 100644 app/Http/Controllers/User/AvatarController.php create mode 100644 app/Http/Controllers/User/BuddiesController.php create mode 100644 app/Http/Controllers/User/FavouritesController.php create mode 100644 app/Http/Controllers/User/MembersController.php create mode 100644 app/Http/Controllers/User/MonthlyCommentatorsController.php create mode 100644 app/Http/Controllers/User/MyBuddiesController.php rename app/Http/Controllers/{ => User}/ProfileController.php (78%) create mode 100644 app/Http/Controllers/User/ReceivedCommentsController.php create mode 100644 app/Http/Controllers/User/StatisticsController.php create mode 100644 app/Http/Controllers/User/TodayDownloadsController.php create mode 100644 app/Http/Controllers/User/TodayInHistoryController.php create mode 100644 app/Http/Controllers/User/TopAuthorsController.php create mode 100644 app/Http/Controllers/User/TopFavouritesController.php create mode 100644 app/Http/Controllers/User/UserController.php create mode 100644 app/Http/Controllers/Web/ArtController.php rename app/Http/Controllers/{ => Web}/BrowseCategoriesController.php (78%) rename app/Http/Controllers/{ => Web}/BrowseGalleryController.php (96%) create mode 100644 app/Http/Controllers/Web/CategoryController.php create mode 100644 app/Http/Controllers/Web/DailyUploadsController.php create mode 100644 app/Http/Controllers/Web/FeaturedArtworksController.php create mode 100644 app/Http/Controllers/Web/GalleryController.php create mode 100644 app/Http/Controllers/Web/HomeController.php create mode 100644 app/Http/Requests/AvatarUploadRequest.php create mode 100644 app/Support/AvatarUrl.php create mode 100644 avatar_patch.diff create mode 100644 config/avatars.php create mode 100644 database/migrations/2026_02_15_000100_finalize_avatar_schema_for_cdn.php create mode 100644 database/migrations/2026_02_15_120500_finalize_avatar_metadata_schema.php create mode 100644 docs/avatar-cdn-config-notes.md create mode 100644 playwright.config.ts create mode 100644 public/gfx/sb_logo.png create mode 100644 public/gfx/sb_logo2.png create mode 100644 public/gfx/sb_logo_white.png create mode 100644 resources/js/components/profile/AvatarUploader.jsx rename resources/views/{legacy => community}/chat.blade.php (98%) create mode 100644 resources/views/layouts/_legacy.blade.php delete mode 100644 resources/views/layouts/legacy.blade.php rename resources/views/{legacy => shared}/placeholder.blade.php (100%) rename resources/views/{legacy => user}/buddies.blade.php (90%) rename resources/views/{legacy => user}/favourites.blade.php (99%) rename resources/views/{legacy => web}/art.blade.php (92%) rename resources/views/{legacy => web}/categories.blade.php (99%) rename resources/views/{legacy => web}/category.blade.php (97%) rename resources/views/{legacy => web}/daily-uploads.blade.php (93%) rename resources/views/{legacy => web}/featured-artworks.blade.php (95%) rename resources/views/{legacy => web}/gallery.blade.php (91%) create mode 100644 resources/views/web/home.blade.php create mode 100644 resources/views/web/home/featured.blade.php create mode 100644 resources/views/web/home/news.blade.php create mode 100644 resources/views/web/home/uploads.blade.php create mode 100644 resources/views/web/partials/_artwork_card.blade.php rename resources/views/{legacy => web}/partials/daily-uploads-grid.blade.php (100%) delete mode 100644 routes/legacy.php create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/home.spec.ts diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php index 45f893d1..6267e3cd 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}'; + protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Migrate legacy avatars to new WebP avatar storage'; + protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata'; - protected $service; - - public function __construct(AvatarService $service) + 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; } - // 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; - } - } + $path = $this->locateLegacyAvatarPath($userId, $legacyName); + if ($path === null) { + $failed++; + $this->warn("User {$userId}: legacy avatar not found ({$legacyName})"); + continue; } - if (!$found) { - $this->warn("Legacy file not found for user {$userId}, filename={$legacy}"); + 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("Migration complete. Processed: {$count}"); - return 0; + $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), + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) { + return $candidate; + } + } + + return null; } } diff --git a/app/Http/Controllers/AvatarController.php b/app/Http/Controllers/AvatarController.php deleted file mode 100644 index b80150de..00000000 --- a/app/Http/Controllers/AvatarController.php +++ /dev/null @@ -1,47 +0,0 @@ -service = $service; - } - - /** - * Handle avatar upload request. - */ - public function upload(Request $request) - { - $user = Auth::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 { - $hash = $this->service->storeFromUploadedFile($user->id, $file); - return response()->json(['success' => true, 'hash' => $hash], 200); - } catch (\Exception $e) { - return response()->json(['error' => 'Processing failed', 'message' => $e->getMessage()], 500); - } - } -} diff --git a/app/Http/Controllers/Community/ChatController.php b/app/Http/Controllers/Community/ChatController.php new file mode 100644 index 00000000..7cfff4bd --- /dev/null +++ b/app/Http/Controllers/Community/ChatController.php @@ -0,0 +1,44 @@ +input('store_chat'); + $chat_text = $request->input('chat_txt'); + + $chat = new \App\Chat(); + + if (!empty($store) && $store === 'true' && !empty($chat_text)) { + if (!empty($_SESSION['web_login']['status'])) { + $chat->StoreMessage($chat_text); + $chat->UpdateChatFile('cron/chat_log.txt', 10); + } + } + + ob_start(); + \App\Banner::ShowResponsiveAd(); + $adHtml = ob_get_clean(); + + ob_start(); + $userID = $_SESSION['web_login']['user_id'] ?? null; + $chat->ShowChat(50, $userID); + $chatHtml = ob_get_clean(); + + try { + $smileys = DB::table('smileys')->select('code', 'picture', 'emotion')->get(); + } catch (\Throwable $e) { + $smileys = collect(); + } + + return view('community.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys')); + } +} diff --git a/app/Http/Controllers/Community/ForumController.php b/app/Http/Controllers/Community/ForumController.php new file mode 100644 index 00000000..5ec82a4f --- /dev/null +++ b/app/Http/Controllers/Community/ForumController.php @@ -0,0 +1,38 @@ +legacy = $legacy; + } + + public function index() + { + $data = $this->legacy->forumIndex(); + return view('community.forum.index', $data); + } + + public function topic(Request $request, $topic_id) + { + $data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1)); + + if (! $data) { + return view('shared.placeholder'); + } + + if (isset($data['type']) && $data['type'] === 'subtopics') { + return view('community.forum.topic', $data); + } + + return view('community.forum.posts', $data); + } +} diff --git a/app/Http/Controllers/Community/InterviewController.php b/app/Http/Controllers/Community/InterviewController.php new file mode 100644 index 00000000..f1352925 --- /dev/null +++ b/app/Http/Controllers/Community/InterviewController.php @@ -0,0 +1,103 @@ +isMethod('post')) { + $action = $request->input('action'); + if ($action === 'store' && (!empty($_SESSION['web_login']['user_type']) && $_SESSION['web_login']['user_type'] > 1)) { + $comment = $request->input('comment'); + $tekst = nl2br(htmlspecialchars($comment ?? '', ENT_QUOTES, 'UTF-8')); + $interviewId = (int) $request->input('interview_id'); + + try { + DB::table('interviews_comment')->insert([ + 'nid' => $interviewId, + 'author' => $_SESSION['web_login']['username'] ?? 'Anonymous', + 'datum' => DB::raw('CURRENT_TIMESTAMP'), + 'tekst' => $tekst, + ]); + + $ar2 = DB::table('users') + ->where('uname', $_SESSION['web_login']['username']) + ->first(); + + if (!empty($ar2->user_id)) { + DB::table('users_statistics') + ->where('user_id', $ar2->user_id) + ->increment('newscomment'); + } + } catch (\Throwable $e) { + // fail silently + } + } + } + + try { + $ar = DB::table('interviews')->where('id', $id)->first(); + } catch (\Throwable $e) { + $ar = null; + } + + if (! $ar) { + return redirect('/interviews'); + } + + try { + $artworks = DB::table('wallz') + ->where('uname', $ar->username) + ->inRandomOrder() + ->limit(2) + ->get(); + } catch (\Throwable $e) { + $artworks = collect(); + } + + try { + $comments = DB::table('interviews_comment as c') + ->leftJoin('users as u', 'u.uname', '=', 'c.author') + ->where('c.nid', $id) + ->select('c.*', 'u.user_id', 'u.user_type', 'u.signature', 'u.icon') + ->orderBy('c.datum') + ->get(); + } catch (\Throwable $e) { + $comments = collect(); + } + + $authors = $comments->pluck('author')->unique()->values()->all(); + $postCounts = []; + if (!empty($authors)) { + try { + $counts = DB::table('interviews_comment') + ->select('author', DB::raw('COUNT(*) as cnt')) + ->whereIn('author', $authors) + ->groupBy('author') + ->get(); + + foreach ($counts as $c) { + $postCounts[$c->author] = $c->cnt; + } + } catch (\Throwable $e) { + } + } + + $page_title = 'Interview with ' . ($ar->username ?? ''); + + return view('community.interview', [ + 'ar' => $ar, + 'artworks' => $artworks, + 'comments' => $comments, + 'postCounts' => $postCounts, + 'page_title' => $page_title, + ]); + } +} diff --git a/app/Http/Controllers/Community/LatestCommentsController.php b/app/Http/Controllers/Community/LatestCommentsController.php new file mode 100644 index 00000000..14c4cb6b --- /dev/null +++ b/app/Http/Controllers/Community/LatestCommentsController.php @@ -0,0 +1,54 @@ +whereHas('artwork', function ($q) { + $q->public()->published()->whereNull('deleted_at'); + }) + ->orderByDesc('created_at'); + + $comments = $query->paginate($hits)->withQueryString(); + + $comments->getCollection()->transform(function (ArtworkComment $c) { + $art = $c->artwork; + $user = $c->user; + + $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; + $thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg'; + + return (object) [ + 'comment_id' => $c->getKey(), + 'comment_description' => $c->content, + 'commenter_id' => $c->user_id, + 'country' => $user->country ?? 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, + 'id' => $art->id ?? null, + 'name' => $art->title ?? null, + 'picture' => $art->file_name ?? null, + 'thumb' => $thumb, + 'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''), + 'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(), + ]; + }); + + $page_title = 'Latest Comments'; + + return view('community.latest-comments', compact('page_title', 'comments')); + } +} diff --git a/app/Http/Controllers/Community/LatestController.php b/app/Http/Controllers/Community/LatestController.php new file mode 100644 index 00000000..5f0c4723 --- /dev/null +++ b/app/Http/Controllers/Community/LatestController.php @@ -0,0 +1,47 @@ +artworks = $artworks; + } + + public function index(Request $request) + { + $perPage = 21; + + $artworks = $this->artworks->browsePublicArtworks($perPage); + + $artworks->getCollection()->transform(function (Artwork $artwork) { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $categoryName = $primaryCategory->name ?? ''; + $gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0; + $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); + + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'category_name' => $categoryName, + 'gid_num' => $gid, + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user->name ?? 'Skinbase', + ]; + }); + + return view('community.latest-artworks', [ + 'artworks' => $artworks, + 'page_title' => 'Latest Artworks', + ]); + } +} diff --git a/app/Http/Controllers/Community/NewsController.php b/app/Http/Controllers/Community/NewsController.php new file mode 100644 index 00000000..cd21418f --- /dev/null +++ b/app/Http/Controllers/Community/NewsController.php @@ -0,0 +1,44 @@ +leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') + ->where('t1.news_id', $id) + ->select('t1.*', 't2.uname', 't2.user_type', 't2.signature', 't2.icon') + ->first(); + } catch (\Throwable $e) { + $news = null; + } + + if (empty($news)) { + return redirect('/'); + } + + try { + $comments = DB::table('news_comment as c') + ->leftJoin('users as u', 'c.user_id', '=', 'u.user_id') + ->where('c.news_id', $id) + ->select('c.posted', 'c.message', 'c.user_id', 'u.user_type', 'u.signature', 'u.icon', 'u.uname') + ->orderBy('c.posted') + ->get(); + } catch (\Throwable $e) { + $comments = collect(); + } + + $page_title = ($news->headline ?? 'News') . ' - SkinBase News'; + + return view('community.news', compact('news', 'comments', 'page_title')); + } +} diff --git a/app/Http/Controllers/ManageController.php b/app/Http/Controllers/Dashboard/ManageController.php similarity index 83% rename from app/Http/Controllers/ManageController.php rename to app/Http/Controllers/Dashboard/ManageController.php index f8f53576..ef58bfd0 100644 --- a/app/Http/Controllers/ManageController.php +++ b/app/Http/Controllers/Dashboard/ManageController.php @@ -1,7 +1,8 @@ user()->id; $perPage = 50; - // Use default connection query builder and join category name to avoid Eloquent model issues $categorySub = DB::table('artwork_category as ac') ->join('categories as c', 'ac.category_id', '=', 'c.id') ->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name')) @@ -26,8 +26,17 @@ class ManageController extends Controller ->leftJoinSub($categorySub, 'cat', function ($join) { $join->on('a.id', '=', 'cat.artwork_id'); }) + ->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id') ->where('a.user_id', $userId) - ->select('a.*', DB::raw('cat.category_name as category_name')) + ->select( + 'a.*', + DB::raw('cat.category_name as category_name'), + DB::raw('COALESCE(s.rating_count, 0) as rating_num'), + DB::raw('COALESCE(s.rating_avg, 0) as rating'), + DB::raw('COALESCE(s.downloads, 0) as dls'), + DB::raw('COALESCE(s.favorites, 0) as zoom'), + DB::raw('COALESCE(s.views, 0) as views') + ) ->orderByDesc('a.published_at') ->orderByDesc('a.id'); @@ -43,7 +52,6 @@ class ManageController extends Controller { $artwork = $request->artwork(); - // If artworks no longer have a single `category` column, fetch pivot selection $selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); $artwork->category = $selectedCategory; @@ -56,7 +64,7 @@ class ManageController extends Controller return view('manage.edit', [ 'artwork' => $artwork, 'categories' => $categories, - 'page_title' => 'Edit Artwork: ' . ($artwork->name ?? ''), + 'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''), ]); } @@ -65,12 +73,11 @@ class ManageController extends Controller $existing = $request->artwork(); $data = $request->validated(); $update = [ - 'name' => $data['name'], + 'title' => $data['title'], 'description' => $data['description'] ?? $existing->description, 'updated' => now(), ]; - // handle artwork image upload (replacing picture) if ($request->hasFile('artwork')) { $file = $request->file('artwork'); $path = $file->store('public/uploads/artworks'); @@ -78,7 +85,6 @@ class ManageController extends Controller $update['picture'] = $filename; } - // handle attachment upload (zip, etc.) if ($request->hasFile('attachment')) { $att = $request->file('attachment'); $attPath = $att->store('public/uploads/attachments'); @@ -87,7 +93,6 @@ class ManageController extends Controller DB::table('artworks')->where('id', (int)$id)->update($update); - // Update pivot: set single category selection for this artwork if (isset($data['section'])) { DB::table('artwork_category')->where('artwork_id', (int)$id)->delete(); DB::table('artwork_category')->insert([ @@ -103,7 +108,6 @@ class ManageController extends Controller { $artwork = $request->artwork(); - // delete files if present (stored in new storage location) if (!empty($artwork->fname)) { Storage::delete('public/uploads/attachments/' . $artwork->fname); } diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php new file mode 100644 index 00000000..4f4558fa --- /dev/null +++ b/app/Http/Controllers/InterviewController.php @@ -0,0 +1,103 @@ +isMethod('post')) { + $action = $request->input('action'); + if ($action === 'store' && (!empty($_SESSION['web_login']['user_type']) && $_SESSION['web_login']['user_type'] > 1)) { + $comment = $request->input('comment'); + $tekst = nl2br(htmlspecialchars($comment ?? '', ENT_QUOTES, 'UTF-8')); + $interviewId = (int) $request->input('interview_id'); + + try { + DB::table('interviews_comment')->insert([ + 'nid' => $interviewId, + 'author' => $_SESSION['web_login']['username'] ?? 'Anonymous', + 'datum' => DB::raw('CURRENT_TIMESTAMP'), + 'tekst' => $tekst, + ]); + + $ar2 = DB::table('users') + ->where('uname', $_SESSION['web_login']['username']) + ->first(); + + if (!empty($ar2->user_id)) { + DB::table('users_statistics') + ->where('user_id', $ar2->user_id) + ->increment('newscomment'); + } + } catch (\Throwable $e) { + // fail silently + } + } + } + + try { + $ar = DB::table('interviews')->where('id', $id)->first(); + } catch (\Throwable $e) { + $ar = null; + } + + if (! $ar) { + return redirect('/interviews'); + } + + try { + $artworks = DB::table('wallz') + ->where('uname', $ar->username) + ->inRandomOrder() + ->limit(2) + ->get(); + } catch (\Throwable $e) { + $artworks = collect(); + } + + try { + $comments = DB::table('interviews_comment as c') + ->leftJoin('users as u', 'u.uname', '=', 'c.author') + ->where('c.nid', $id) + ->select('c.*', 'u.user_id', 'u.user_type', 'u.signature', 'u.icon') + ->orderBy('c.datum') + ->get(); + } catch (\Throwable $e) { + $comments = collect(); + } + + $authors = $comments->pluck('author')->unique()->values()->all(); + $postCounts = []; + if (!empty($authors)) { + try { + $counts = DB::table('interviews_comment') + ->select('author', DB::raw('COUNT(*) as cnt')) + ->whereIn('author', $authors) + ->groupBy('author') + ->get(); + + foreach ($counts as $c) { + $postCounts[$c->author] = $c->cnt; + } + } catch (\Throwable $e) { + } + } + + $page_title = 'Interview with ' . ($ar->username ?? ''); + + return view('legacy.interview', [ + 'ar' => $ar, + 'artworks' => $artworks, + 'comments' => $comments, + 'postCounts' => $postCounts, + 'page_title' => $page_title, + ]); + } +} diff --git a/app/Http/Controllers/InterviewsController.php b/app/Http/Controllers/InterviewsController.php new file mode 100644 index 00000000..5b7dcf5e --- /dev/null +++ b/app/Http/Controllers/InterviewsController.php @@ -0,0 +1,28 @@ +select('t1.id', 't1.headline', 't2.user_id', 't2.uname', 't2.icon') + ->leftJoin('users AS t2', 't1.username', '=', 't2.uname') + ->orderByDesc('t1.datum') + ->limit(60) + ->get(); + } catch (\Throwable $e) { + $interviews = collect(); + } + + $page_title = 'Interviews'; + + return view('legacy.interviews', compact('interviews', 'page_title')); + } +} diff --git a/app/Http/Controllers/Legacy/AvatarController.php b/app/Http/Controllers/Legacy/AvatarController.php index eb322a07..391c514f 100644 --- a/app/Http/Controllers/Legacy/AvatarController.php +++ b/app/Http/Controllers/Legacy/AvatarController.php @@ -4,64 +4,15 @@ namespace App\Http\Controllers\Legacy; 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 cfb3b040..d1ec9ccc 100644 --- a/app/Http/Controllers/Legacy/BuddiesController.php +++ b/app/Http/Controllers/Legacy/BuddiesController.php @@ -22,7 +22,7 @@ class BuddiesController extends Controller ->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 eb091e27..02db175f 100644 --- a/app/Http/Controllers/Legacy/LatestCommentsController.php +++ b/app/Http/Controllers/Legacy/LatestCommentsController.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; 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 @@ class LatestCommentsController extends Controller '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 3ff2f962..9ab8db4b 100644 --- a/app/Http/Controllers/Legacy/MyBuddiesController.php +++ b/app/Http/Controllers/Legacy/MyBuddiesController.php @@ -23,7 +23,7 @@ class MyBuddiesController extends Controller ->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 dfe911c0..5d3b1497 100644 --- a/app/Http/Controllers/Legacy/ProfileController.php +++ b/app/Http/Controllers/Legacy/ProfileController.php @@ -10,6 +10,7 @@ use App\Models\User; 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 @@ class ProfileController extends Controller '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 a8a35464..e7286a83 100644 --- a/app/Http/Controllers/Legacy/UserController.php +++ b/app/Http/Controllers/Legacy/UserController.php @@ -4,11 +4,10 @@ namespace App\Http\Controllers\Legacy; 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 @@ class UserController extends Controller // 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 @@ class UserController extends Controller 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/Misc/AvatarController.php b/app/Http/Controllers/Misc/AvatarController.php new file mode 100644 index 00000000..fd1bf0ed --- /dev/null +++ b/app/Http/Controllers/Misc/AvatarController.php @@ -0,0 +1,55 @@ +service = $service; + } + + public function upload(AvatarUploadRequest $request): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $file = $request->file('avatar'); + + try { + $hash = $this->service->storeFromUploadedFile($user->id, $file); + return response()->json([ + 'success' => true, + 'hash' => $hash, + 'url' => AvatarUrl::forUser((int) $user->id, $hash, 128), + ], 200); + } catch (RuntimeException $e) { + logger()->warning('Avatar upload validation failed', [ + 'user_id' => (int) $user->id, + 'message' => $e->getMessage(), + ]); + + return response()->json([ + 'error' => 'Validation failed', + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + logger()->error('Avatar upload failed', [ + 'user_id' => (int) $user->id, + 'message' => $e->getMessage(), + ]); + + return response()->json(['error' => 'Processing failed'], 500); + } + } +} diff --git a/app/Http/Controllers/PhotographyController.php b/app/Http/Controllers/PhotographyController.php new file mode 100644 index 00000000..1b1483e6 --- /dev/null +++ b/app/Http/Controllers/PhotographyController.php @@ -0,0 +1,101 @@ +artworks = $artworks; + } + + public function index(Request $request) + { + $segment = strtolower($request->segment(1) ?? 'photography'); + $contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography'; + + $group = ucfirst($contentSlug); + + $id = null; + if ($contentSlug === 'photography') { + $id = 3; + } + + $category = null; + try { + if ($id !== null && Schema::hasTable('artworks_categories')) { + $category = DB::table('artworks_categories') + ->select('category_name', 'rootid', 'section_id', 'description', 'category_id') + ->where('category_id', $id) + ->first(); + } + } catch (\Throwable $e) { + $category = null; + } + + $ct = ContentType::where('slug', $contentSlug)->first(); + $page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug)); + $tidy = $category->description ?? ($ct->description ?? null); + + $perPage = 40; + $sort = (string) $request->get('sort', 'latest'); + + try { + $artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort); + } catch (\Throwable $e) { + $artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [ + 'path' => url()->current(), + ]); + } + + $subcategories = collect(); + try { + if ($id !== null && Schema::hasTable('artworks_categories')) { + $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get(); + if ($subcategories->count() == 0 && !empty($category->rootid)) { + $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get(); + } + } + } catch (\Throwable $e) { + $subcategories = collect(); + } + + if (! $subcategories || $subcategories->count() === 0) { + if ($ct) { + $subcategories = $ct->rootCategories() + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); + } else { + $subcategories = collect(); + } + } + + if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) { + $page = (int) ($request->query('page', 1)); + $artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [ + 'path' => url()->current(), + 'query' => request()->query(), + ]); + } + + $contentType = ContentType::where('slug', $contentSlug)->first(); + $rootCategories = $contentType + ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() + : collect(); + + $page_meta_description = $tidy; + + return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id')); + } +} diff --git a/app/Http/Controllers/User/AvatarController.php b/app/Http/Controllers/User/AvatarController.php new file mode 100644 index 00000000..4a3f4f1d --- /dev/null +++ b/app/Http/Controllers/User/AvatarController.php @@ -0,0 +1,59 @@ +service = $service; + } + + /** + * Handle avatar upload request. + */ + public function upload(AvatarUploadRequest $request): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $file = $request->file('avatar'); + + try { + $hash = $this->service->storeFromUploadedFile($user->id, $file); + return response()->json([ + 'success' => true, + 'hash' => $hash, + 'url' => AvatarUrl::forUser((int) $user->id, $hash, 128), + ], 200); + } catch (RuntimeException $e) { + logger()->warning('Avatar upload validation failed', [ + 'user_id' => (int) $user->id, + 'message' => $e->getMessage(), + ]); + + return response()->json([ + 'error' => 'Validation failed', + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + logger()->error('Avatar upload failed', [ + 'user_id' => (int) $user->id, + 'message' => $e->getMessage(), + ]); + + return response()->json(['error' => 'Processing failed'], 500); + } + } +} diff --git a/app/Http/Controllers/User/BuddiesController.php b/app/Http/Controllers/User/BuddiesController.php new file mode 100644 index 00000000..379d0e21 --- /dev/null +++ b/app/Http/Controllers/User/BuddiesController.php @@ -0,0 +1,37 @@ +user(); + if (! $user) { + return redirect()->route('login'); + } + + $perPage = 50; + + try { + $query = DB::table('friends_list as t1') + ->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_hash as icon', 't1.date_added') + ->orderByDesc('t1.date_added'); + + $followers = $query->paginate($perPage)->withQueryString(); + } catch (\Throwable $e) { + $followers = collect(); + } + + $page_title = ($user->name ?? $user->username ?? 'User') . ': Followers'; + + return view('user.buddies', compact('followers', 'page_title')); + } +} diff --git a/app/Http/Controllers/User/FavouritesController.php b/app/Http/Controllers/User/FavouritesController.php new file mode 100644 index 00000000..8be69d96 --- /dev/null +++ b/app/Http/Controllers/User/FavouritesController.php @@ -0,0 +1,143 @@ +user()->id ?? null); + + $page = max(1, (int) $request->query('page', 1)); + $hits = 20; + $start = ($page - 1) * $hits; + + $total = 0; + $results = collect(); + + try { + $schema = DB::getSchemaBuilder(); + } catch (\Throwable $e) { + $schema = null; + } + + $userIdCol = Schema::hasColumn('users', 'user_id') ? 'user_id' : 'id'; + $userNameCol = null; + foreach (['uname', 'username', 'name'] as $col) { + if (Schema::hasColumn('users', $col)) { + $userNameCol = $col; + break; + } + } + + if ($schema && $schema->hasTable('user_favorites') && class_exists(UserFavorite::class)) { + try { + $query = UserFavorite::with(['artwork.user']) + ->where('user_id', $userId) + ->orderByDesc('created_at') + ->orderByDesc('artwork_id'); + + $total = (int) $query->count(); + + $favorites = $query->skip($start)->take($hits)->get(); + + $results = $favorites->map(function ($fav) use ($userNameCol) { + $art = $fav->artwork; + if (! $art) { + return null; + } + $item = (object) $art->toArray(); + $item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null; + $item->datum = $fav->created_at; + return $item; + })->filter(); + } catch (\Throwable $e) { + $total = 0; + $results = collect(); + } + } else { + try { + if ($schema && $schema->hasTable('artworks_favourites')) { + $favTable = 'artworks_favourites'; + } elseif ($schema && $schema->hasTable('favourites')) { + $favTable = 'favourites'; + } else { + $favTable = null; + } + + if ($schema && $schema->hasTable('artworks')) { + $artTable = 'artworks'; + } elseif ($schema && $schema->hasTable('wallz')) { + $artTable = 'wallz'; + } else { + $artTable = null; + } + } catch (\Throwable $e) { + $favTable = null; + $artTable = null; + } + + if ($favTable && $artTable) { + try { + $total = (int) DB::table($favTable)->where('user_id', $userId)->count(); + + $t2JoinCol = 't2.' . $userIdCol; + $t2NameSelect = $userNameCol ? DB::raw("t2.{$userNameCol} as uname") : DB::raw("'' as uname"); + + $results = DB::table($favTable . ' as t1') + ->rightJoin($artTable . ' as t3', 't1.artwork_id', '=', 't3.id') + ->leftJoin('users as t2', 't3.user_id', '=', $t2JoinCol) + ->where('t1.user_id', $userId) + ->select('t3.*', $t2NameSelect, 't1.datum') + ->orderByDesc('t1.datum') + ->orderByDesc('t1.artwork_id') + ->skip($start) + ->take($hits) + ->get(); + } catch (\Throwable $e) { + $total = 0; + $results = collect(); + } + } + } + + $results = collect($results)->filter()->values()->transform(function ($row) { + $row->name = $row->name ?? ''; + $row->slug = $row->slug ?? Str::slug($row->name); + $row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int)$row->id) : null; + return $row; + }); + + $page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites'; + + return view('user.favourites', [ + 'results' => $results, + 'page_title' => $page_title, + 'user_id' => $userId, + 'page' => $page, + 'hits' => $hits, + 'total' => $total, + ]); + } + + public function destroy(Request $request, $userId, $artworkId) + { + $auth = $request->user(); + if (! $auth || $auth->id != (int)$userId) { + abort(403); + } + + $favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('artworks_favourites') ? 'artworks_favourites' : 'favourites'); + + DB::table($favTable)->where('user_id', (int)$userId)->where('artwork_id', (int)$artworkId)->delete(); + + return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites'); + } +} diff --git a/app/Http/Controllers/User/MembersController.php b/app/Http/Controllers/User/MembersController.php new file mode 100644 index 00000000..d1320f8d --- /dev/null +++ b/app/Http/Controllers/User/MembersController.php @@ -0,0 +1,49 @@ +legacy = $legacy; + } + + public function photos(Request $request, $id = null) + { + $id = (int) ($id ?: 545); + + $result = $this->legacy->categoryPage('', null, $id); + if (! $result) { + return redirect('/'); + } + + $page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos'); + $artworks = $result['artworks'] ?? collect(); + + if ($artworks && method_exists($artworks, 'getCollection')) { + $artworks->getCollection()->transform(function ($row) { + $row->slug = $row->slug ?? Str::slug($row->name ?? ''); + $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); + $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); + return $row; + }); + } elseif (is_iterable($artworks)) { + $artworks = collect($artworks)->map(function ($row) { + $row->slug = $row->slug ?? Str::slug($row->name ?? ''); + $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); + $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); + return $row; + }); + } + + return view('web.browse', compact('page_title', 'artworks')); + } +} diff --git a/app/Http/Controllers/User/MonthlyCommentatorsController.php b/app/Http/Controllers/User/MonthlyCommentatorsController.php new file mode 100644 index 00000000..a89cae22 --- /dev/null +++ b/app/Http/Controllers/User/MonthlyCommentatorsController.php @@ -0,0 +1,39 @@ +query('page', 1)); + + $query = DB::table('artwork_comments as t1') + ->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') + ->leftJoin('country as c', 't2.country', '=', 'c.id') + ->where('t1.user_id', '>', 0) + ->whereRaw("DATE_SUB(CURDATE(), INTERVAL 30 DAY) <= t1.date") + ->select( + 't2.user_id', + 't2.uname', + 't2.user_type', + 't2.country', + 'c.name as country_name', + 'c.flag as country_flag', + DB::raw('COUNT(*) as num_comments') + ) + ->groupBy('t1.user_id') + ->orderByDesc('num_comments'); + + $rows = $query->paginate($hits)->withQueryString(); + + $page_title = 'Monthly Top Commentators'; + + return view('user.monthly-commentators', compact('page_title', 'rows')); + } +} diff --git a/app/Http/Controllers/User/MyBuddiesController.php b/app/Http/Controllers/User/MyBuddiesController.php new file mode 100644 index 00000000..85b33f1c --- /dev/null +++ b/app/Http/Controllers/User/MyBuddiesController.php @@ -0,0 +1,56 @@ +user(); + if (! $user) { + return redirect()->route('login'); + } + + $perPage = 50; + + try { + $query = DB::table('friends_list as t1') + ->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_hash as icon', 't1.date_added') + ->orderByDesc('t1.date_added'); + + $buddies = $query->paginate($perPage)->withQueryString(); + } catch (\Throwable $e) { + $buddies = collect(); + } + + $page_title = ($user->name ?? $user->username ?? 'User') . ': Following List'; + + return view('user.mybuddies', compact('buddies', 'page_title')); + } + + public function destroy(Request $request, $id) + { + $user = $request->user(); + if (! $user) { + abort(403); + } + + try { + $deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete(); + if ($deleted) { + $request->session()->flash('status', 'Removed from following list.'); + } + } catch (\Throwable $e) { + $request->session()->flash('error', 'Could not remove buddy.'); + } + + return redirect()->route('legacy.mybuddies'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/User/ProfileController.php similarity index 78% rename from app/Http/Controllers/ProfileController.php rename to app/Http/Controllers/User/ProfileController.php index d2d80cb6..ed954c80 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -1,7 +1,8 @@ user(); - // Core fields $validated = $request->validated(); logger()->debug('Profile update validated data', $validated); - // Username is read-only and must not be changed here. - // Use `name` for the real/display name field. if (isset($validated['name'])) { $user->name = $validated['name']; } - // Only allow setting email when we don't have one yet (legacy users) if (!empty($validated['email']) && empty($user->email)) { $user->email = $validated['email']; $user->email_verified_at = null; @@ -49,18 +40,15 @@ class ProfileController extends Controller $user->save(); - // Profile fields - target columns in `user_profiles` per spec $profileUpdates = []; if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about']; - // website / legacy homepage if (!empty($validated['web'])) { $profileUpdates['website'] = $validated['web']; } elseif (!empty($validated['homepage'])) { $profileUpdates['website'] = $validated['homepage']; } - // Birthday -> store as birthdate $day = $validated['day'] ?? null; $month = $validated['month'] ?? null; $year = $validated['year'] ?? null; @@ -68,7 +56,6 @@ class ProfileController extends Controller $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day); } - // Gender normalization -> store as provided normalized value if (!empty($validated['gender'])) { $g = strtolower($validated['gender']); $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; @@ -77,7 +64,6 @@ class ProfileController extends Controller if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country']; - // Mailing and notify flags: normalize true/false when saving if (array_key_exists('mailing', $validated)) { $profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } @@ -85,21 +71,14 @@ class ProfileController extends Controller $profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } - // signature/description should be stored in their own columns if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; - // 'about' direct field (ensure explicit about wins when provided) if (isset($validated['about'])) $profileUpdates['about'] = $validated['about']; - // 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()); } @@ -118,7 +97,6 @@ class ProfileController extends Controller $file = $request->file('photo'); $fname = $file->getClientOriginalName(); $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); - // store cover image filename in user_profiles.cover_image (fallback to users.picture) if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { $profileUpdates['cover_image'] = $fname; } else { @@ -128,7 +106,6 @@ class ProfileController extends Controller } } - // Persist profile updates now that files (avatar/cover) have been handled try { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { if (!empty($profileUpdates)) { @@ -146,9 +123,6 @@ class ProfileController extends Controller return Redirect::to('/user')->with('status', 'profile-updated'); } - /** - * Delete the user's account. - */ public function destroy(Request $request): RedirectResponse { $request->validateWithBag('userDeletion', [ @@ -159,7 +133,6 @@ class ProfileController extends Controller Auth::logout(); - // Soft-delete the user (preserve record) — align with soft-delete policy. $user->delete(); $request->session()->invalidate(); @@ -168,9 +141,6 @@ class ProfileController extends Controller return Redirect::to('/'); } - /** - * Update the user's password. - */ public function password(Request $request): RedirectResponse { $request->validate([ diff --git a/app/Http/Controllers/User/ReceivedCommentsController.php b/app/Http/Controllers/User/ReceivedCommentsController.php new file mode 100644 index 00000000..51cc7f60 --- /dev/null +++ b/app/Http/Controllers/User/ReceivedCommentsController.php @@ -0,0 +1,27 @@ +user(); + if (! $user) { + return redirect()->route('login'); + } + + try { + $comments = app(\App\Services\LegacyService::class)->receivedComments($user->id); + } catch (\Throwable $e) { + $comments = collect(); + } + + return view('user.received-comments', [ + 'comments' => $comments, + ]); + } +} diff --git a/app/Http/Controllers/User/StatisticsController.php b/app/Http/Controllers/User/StatisticsController.php new file mode 100644 index 00000000..ea80010f --- /dev/null +++ b/app/Http/Controllers/User/StatisticsController.php @@ -0,0 +1,66 @@ +user()->id; + + $sort = (string) $request->query('sort', 'date'); + $allowed = ['date', 'name', 'dls', 'category', 'comments']; + if (! in_array($sort, $allowed, true)) { + $sort = 'date'; + } + + $categorySub = DB::table('artwork_category as ac') + ->join('categories as c', 'ac.category_id', '=', 'c.id') + ->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name')) + ->groupBy('ac.artwork_id'); + + $query = DB::table('artworks as a') + ->leftJoinSub($categorySub, 'cat', function ($join) { + $join->on('a.id', '=', 'cat.artwork_id'); + }) + ->where('a.user_id', $userId) + ->select([ + 'a.*', + DB::raw('cat.category_name as category_name'), + ]) + ->selectRaw('(SELECT COUNT(*) FROM artwork_comments WHERE artwork_id = a.id) AS num_comments'); + + if ($sort === 'name') { + $query->orderBy('a.name', 'asc'); + } elseif ($sort === 'dls') { + $query->orderByDesc('a.dls'); + } elseif ($sort === 'category') { + $query->orderBy('cat.category_name', 'asc'); + } elseif ($sort === 'comments') { + $query->orderByDesc('num_comments'); + } else { + $query->orderByDesc('a.published_at')->orderByDesc('a.id'); + } + + $artworks = $query->paginate(20)->appends(['sort' => $sort]); + + $artworks->getCollection()->transform(function ($row) { + $thumb = ThumbnailPresenter::present($row, 'sm'); + $row->thumb_url = $thumb['url'] ?? ''; + $row->thumb_srcset = $thumb['srcset'] ?? null; + return $row; + }); + + return view('user.statistics', [ + 'artworks' => $artworks, + 'sort' => $sort, + 'page_title' => 'Artwork Statistics', + ]); + } +} diff --git a/app/Http/Controllers/User/TodayDownloadsController.php b/app/Http/Controllers/User/TodayDownloadsController.php new file mode 100644 index 00000000..e688bdac --- /dev/null +++ b/app/Http/Controllers/User/TodayDownloadsController.php @@ -0,0 +1,64 @@ +toDateString(); + + $query = ArtworkDownload::with(['artwork']) + ->whereDate('created_at', $today) + ->whereHas('artwork', function ($q) { + $q->public()->published()->whereNull('deleted_at'); + }) + ->selectRaw('artwork_id, COUNT(*) as num_downloads') + ->groupBy('artwork_id') + ->orderByDesc('num_downloads'); + + $paginator = $query->paginate($hits)->withQueryString(); + + $paginator->getCollection()->transform(function ($row) { + $art = $row->artwork ?? null; + if (! $art && isset($row->artwork_id)) { + $art = \App\Models\Artwork::find($row->artwork_id); + } + + $name = $art->title ?? null; + $picture = $art->file_name ?? null; + $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; + $encoded = null; + $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; + $thumb = $present ? $present['url'] : '/gfx/sb_join.jpg'; + $categoryId = $art->categories->first()->id ?? null; + + return (object) [ + 'id' => $art->id ?? null, + 'name' => $name, + 'picture' => $picture, + 'slug' => $art->slug ?? Str::slug($name ?? ''), + 'ext' => $ext, + 'encoded' => $encoded, + 'thumb' => $thumb, + 'thumb_srcset' => $thumb, + 'category' => $categoryId, + 'num_downloads' => $row->num_downloads ?? 0, + 'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0, + ]; + }); + + $page_title = 'Today Downloaded Artworks'; + + return view('web.browse', ['page_title' => $page_title, 'artworks' => $paginator]); + } +} diff --git a/app/Http/Controllers/User/TodayInHistoryController.php b/app/Http/Controllers/User/TodayInHistoryController.php new file mode 100644 index 00000000..524f9d30 --- /dev/null +++ b/app/Http/Controllers/User/TodayInHistoryController.php @@ -0,0 +1,53 @@ +leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id') + ->join('categories as t2', 't1.category', '=', 't2.id') + ->where('t1.approved', 1) + ->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())') + ->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())') + ->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', DB::raw('t2.name as category_name')); + + $artworks = $base->orderBy('t0.post_date','desc')->paginate($hits); + } catch (\Throwable $e) { + $artworks = null; + } + + if ($artworks && method_exists($artworks, 'getCollection')) { + $artworks->getCollection()->transform(function ($row) { + $row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; + $row->encoded = \App\Services\LegacyService::encode($row->id); + try { + $art = \App\Models\Artwork::find($row->id); + $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); + $row->thumb_url = $present['url']; + $row->thumb_srcset = $present['srcset']; + } catch (\Throwable $e) { + $present = \App\Services\ThumbnailPresenter::present((array) $row, 'md'); + $row->thumb_url = $present['url']; + $row->thumb_srcset = $present['srcset']; + } + $row->gid_num = ((int)($row->category ?? 0) % 5) * 5; + return $row; + }); + } + + return view('user.today-in-history', [ + 'artworks' => $artworks, + 'page_title' => 'Popular on this day in history', + ]); + } +} diff --git a/app/Http/Controllers/User/TopAuthorsController.php b/app/Http/Controllers/User/TopAuthorsController.php new file mode 100644 index 00000000..314afd4e --- /dev/null +++ b/app/Http/Controllers/User/TopAuthorsController.php @@ -0,0 +1,57 @@ +query('metric', 'views')); + + if (! in_array($metric, ['views', 'downloads'])) { + $metric = 'views'; + } + + $sub = Artwork::query() + ->select('artworks.user_id') + ->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNotNull('artworks.published_at') + ->where('artworks.published_at', '<=', now()) + ->whereNull('artworks.deleted_at') + ->selectRaw('artworks.user_id, SUM(artwork_stats.' . $metric . ') as total_metric, MAX(artworks.published_at) as latest_published') + ->groupBy('artworks.user_id'); + + $query = DB::table(DB::raw('(' . $sub->toSql() . ') as t')) + ->mergeBindings($sub->getQuery()) + ->join('users as u', 'u.id', '=', 't.user_id') + ->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published') + ->orderByDesc('t.total_metric') + ->orderByDesc('t.latest_published'); + + $authors = $query->paginate($perPage)->withQueryString(); + + $authors->getCollection()->transform(function ($row) use ($metric) { + return (object) [ + 'user_id' => $row->user_id, + 'uname' => $row->uname, + 'username' => $row->username, + 'total' => (int) $row->total_metric, + 'metric' => $metric, + ]; + }); + + $page_title = 'Top Authors'; + + return view('user.top-authors', compact('page_title', 'authors', 'metric')); + } +} diff --git a/app/Http/Controllers/User/TopFavouritesController.php b/app/Http/Controllers/User/TopFavouritesController.php new file mode 100644 index 00000000..b0c2ac0a --- /dev/null +++ b/app/Http/Controllers/User/TopFavouritesController.php @@ -0,0 +1,56 @@ +query('page', 1)); + + $base = DB::table('artworks_favourites as t1') + ->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id') + ->where('t2.approved', 1) + ->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num')) + ->groupBy('t1.artwork_id'); + + try { + $paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString(); + } catch (\Throwable $e) { + $paginator = collect(); + } + + if ($paginator && method_exists($paginator, 'getCollection')) { + $paginator->getCollection()->transform(function ($row) { + $row->slug = $row->slug ?? Str::slug($row->name ?? ''); + $ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; + $encoded = \App\Helpers\Thumb::encodeId((int) $row->id); + $row->encoded = $encoded; + $row->ext = $ext; + try { + $art = \App\Models\Artwork::find($row->id); + $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); + $row->thumb = $row->thumb ?? $present['url']; + $row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']); + } catch (\Throwable $e) { + $present = \App\Services\ThumbnailPresenter::present((array) $row, 'md'); + $row->thumb = $row->thumb ?? $present['url']; + $row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']); + } + $row->gid_num = ((int)($row->category ?? 0) % 5) * 5; + return $row; + }); + } + + $page_title = 'Top Favourites'; + + return view('user.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]); + } +} diff --git a/app/Http/Controllers/User/UserController.php b/app/Http/Controllers/User/UserController.php new file mode 100644 index 00000000..dda65e6a --- /dev/null +++ b/app/Http/Controllers/User/UserController.php @@ -0,0 +1,27 @@ +user(); + if (! $user) { + return redirect()->route('login'); + } + + try { + $profile = app(\App\Services\LegacyService::class)->userAccount($user->id); + } catch (\Throwable $e) { + $profile = null; + } + + return view('user.user', [ + 'profile' => $profile, + ]); + } +} diff --git a/app/Http/Controllers/Web/ArtController.php b/app/Http/Controllers/Web/ArtController.php new file mode 100644 index 00000000..c0fd545f --- /dev/null +++ b/app/Http/Controllers/Web/ArtController.php @@ -0,0 +1,61 @@ +legacy = $legacy; + } + + public function show(Request $request, $id, $slug = null) + { + if ($request->isMethod('post') && $request->input('action') === 'store_comment') { + if (auth()->check()) { + try { + DB::table('artwork_comments')->insert([ + 'artwork_id' => (int)$id, + 'owner_user_id' => (int)($request->user()->id ?? 0), + 'user_id' => (int)$request->user()->id, + 'date' => now()->toDateString(), + 'time' => now()->toTimeString(), + 'description' => (string)$request->input('comment_text'), + ]); + } catch (\Throwable $e) { + // ignore DB errors for now + } + } + return redirect()->back(); + } + + $data = $this->legacy->getArtwork((int) $id); + + if (! $data || empty($data['artwork'])) { + return view('shared.placeholder', ['title' => 'Artwork Not Found']); + } + + try { + $comments = DB::table('artwork_comments as t1') + ->rightJoin('users as t2', 't1.user_id', '=', 't2.user_id') + ->select('t1.description', 't1.date', 't1.time', 't2.uname', 't2.signature', 't2.icon', 't2.user_id') + ->where('t1.artwork_id', (int)$id) + ->where('t1.user_id', '>', 0) + ->orderBy('t1.comment_id') + ->get(); + } catch (\Throwable $e) { + $comments = collect(); + } + + $data['comments'] = $comments; + + return view('web.art', $data); + } +} diff --git a/app/Http/Controllers/BrowseCategoriesController.php b/app/Http/Controllers/Web/BrowseCategoriesController.php similarity index 78% rename from app/Http/Controllers/BrowseCategoriesController.php rename to app/Http/Controllers/Web/BrowseCategoriesController.php index 1dc21950..f45bbed6 100644 --- a/app/Http/Controllers/BrowseCategoriesController.php +++ b/app/Http/Controllers/Web/BrowseCategoriesController.php @@ -1,7 +1,8 @@ orderBy('id')->get(); - // Prepare categories grouped by content type and a flat list of root categories $categoriesByType = []; $categories = collect(); foreach ($contentTypes as $ct) { $rootCats = $ct->rootCategories; foreach ($rootCats as $cat) { - // Attach subcategories $cat->subcategories = $cat->children; $categories->push($cat); } diff --git a/app/Http/Controllers/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php similarity index 96% rename from app/Http/Controllers/BrowseGalleryController.php rename to app/Http/Controllers/Web/BrowseGalleryController.php index 78509a23..1660ed4e 100644 --- a/app/Http/Controllers/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -1,14 +1,15 @@ show( + return app(\App\Http\Controllers\ArtController::class)->show( $request, strtolower($contentTypeSlug), trim($categoryPath, '/'), diff --git a/app/Http/Controllers/Web/CategoryController.php b/app/Http/Controllers/Web/CategoryController.php new file mode 100644 index 00000000..385eeae7 --- /dev/null +++ b/app/Http/Controllers/Web/CategoryController.php @@ -0,0 +1,100 @@ +artworkService = $artworkService; + } + + public function show(Request $request, $id, $slug = null, $group = null) + { + $path = trim($request->path(), '/'); + $segments = array_values(array_filter(explode('/', $path))); + + if (count($segments) < 2 || strtolower($segments[0]) !== 'category') { + return view('shared.placeholder'); + } + + $parts = array_slice($segments, 1); + + $first = $parts[0] ?? null; + if ($first !== null && ctype_digit((string) $first)) { + try { + $category = Category::findOrFail((int) $first); + $contentTypeSlug = $category->contentType->slug ?? null; + $canonical = '/' . strtolower($contentTypeSlug) . '/' . $category->full_slug_path; + return redirect($canonical, 301); + } catch (ModelNotFoundException $e) { + abort(404); + } + } + + $contentTypeSlug = array_shift($parts); + $slugs = array_merge([$contentTypeSlug], $parts); + + $perPage = (int) $request->get('per_page', 40); + $sort = (string) $request->get('sort', 'latest'); + + try { + $artworks = $this->artworkService->getArtworksByCategoryPath($slugs, $perPage, $sort); + } catch (ModelNotFoundException $e) { + abort(404); + } + + try { + $category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) { + $q->where('slug', strtolower($contentTypeSlug)); + })->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first(); + + if ($category && count($parts) > 1) { + $cur = $category; + foreach (array_slice($parts, 1) as $slugPart) { + $cur = $cur->children()->where('slug', strtolower($slugPart))->first(); + if (! $cur) { + abort(404); + } + } + $category = $cur; + } + } catch (\Throwable $e) { + $category = null; + } + + if (! $category) { + abort(404); + } + + $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); + + $page_title = $category->name; + $page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase'); + $page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography'; + + return view('web.category', compact( + 'page_title', + 'page_meta_description', + 'page_meta_keywords', + 'group', + 'category', + 'subcategories', + 'artworks' + )); + } + + public function browseCategories() + { + $data = app(\App\Services\LegacyService::class)->browseCategories(); + return view('web.categories', $data); + } +} diff --git a/app/Http/Controllers/Web/DailyUploadsController.php b/app/Http/Controllers/Web/DailyUploadsController.php new file mode 100644 index 00000000..663e461f --- /dev/null +++ b/app/Http/Controllers/Web/DailyUploadsController.php @@ -0,0 +1,94 @@ +artworks = $artworks; + } + + public function index(Request $request) + { + $isAjax = $request->boolean('ajax'); + $datum = $request->query('datum'); + + if ($isAjax && $datum) { + $arts = $this->fetchByDate($datum); + return view('web.partials.daily-uploads-grid', ['arts' => $arts])->render(); + } + + $dates = []; + for ($x = 0; $x > -15; $x--) { + $ts = strtotime(sprintf('%+d days', $x)); + $dates[] = [ + 'iso' => date('Y-m-d', $ts), + 'label' => date('d. F Y', $ts), + ]; + } + + $recent = $this->fetchRecent(); + + return view('web.daily-uploads', [ + 'dates' => $dates, + 'recent' => $recent, + 'page_title' => 'Daily Uploads', + ]); + } + + private function fetchByDate(string $date) + { + $ars = Artwork::public() + ->published() + ->whereDate('published_at', $date) + ->orderByDesc('published_at') + ->with(['user:id,name', 'categories' => function ($q) { + $q->select('categories.id', 'categories.name', 'categories.sort_order'); + }]) + ->get(); + + return $this->prepareArts($ars); + } + + private function fetchRecent() + { + $start = now()->subDays(7)->startOfDay(); + + $ars = Artwork::public() + ->published() + ->where('published_at', '>=', $start) + ->orderByDesc('published_at') + ->with(['user:id,name', 'categories' => function ($q) { + $q->select('categories.id', 'categories.name', 'categories.sort_order'); + }]) + ->get(); + + return $this->prepareArts($ars); + } + + private function prepareArts($ars) + { + return $ars->map(function (Artwork $ar) { + $primaryCategory = $ar->categories->sortBy('sort_order')->first(); + $present = \App\Services\ThumbnailPresenter::present($ar, 'md'); + + return (object) [ + 'id' => $ar->id, + 'name' => $ar->title, + 'thumb' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, + 'category_name' => $primaryCategory->name ?? '', + 'uname' => $ar->user->name ?? 'Skinbase', + ]; + }); + } +} diff --git a/app/Http/Controllers/Web/FeaturedArtworksController.php b/app/Http/Controllers/Web/FeaturedArtworksController.php new file mode 100644 index 00000000..23dbf7cf --- /dev/null +++ b/app/Http/Controllers/Web/FeaturedArtworksController.php @@ -0,0 +1,62 @@ +artworks = $artworks; + } + + public function index(Request $request) + { + $perPage = 39; + + $type = (int) ($request->query('type', 4)); + + $typeFilter = $type === 4 ? null : $type; + + $artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage); + + $artworks->getCollection()->transform(function (Artwork $artwork) { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $categoryName = $primaryCategory->name ?? ''; + $gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0; + $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); + + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'category_name' => $categoryName, + 'gid_num' => $gid, + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user->name ?? 'Skinbase', + ]; + }); + + $artworkTypes = [ + 1 => 'Bronze Awards', + 2 => 'Silver Awards', + 3 => 'Gold Awards', + 4 => 'Featured Artworks', + ]; + + $pageTitle = $artworkTypes[$type] ?? 'Featured Artworks'; + + return view('web.featured-artworks', [ + 'artworks' => $artworks, + 'type' => $type, + 'artworkTypes' => $artworkTypes, + 'page_title' => $pageTitle, + ]); + } +} diff --git a/app/Http/Controllers/Web/GalleryController.php b/app/Http/Controllers/Web/GalleryController.php new file mode 100644 index 00000000..87945079 --- /dev/null +++ b/app/Http/Controllers/Web/GalleryController.php @@ -0,0 +1,41 @@ +query('page', 1)); + $hits = 20; + + $query = Artwork::where('user_id', $user->id) + ->approved() + ->published() + ->public() + ->orderByDesc('published_at'); + + $total = (int) $query->count(); + + $artworks = $query->skip(($page - 1) * $hits)->take($hits)->get(); + + return view('web.gallery', [ + 'user' => $user, + 'artworks' => $artworks, + 'page' => $page, + 'hits' => $hits, + 'total' => $total, + ]); + } +} diff --git a/app/Http/Controllers/Web/HomeController.php b/app/Http/Controllers/Web/HomeController.php new file mode 100644 index 00000000..19f02566 --- /dev/null +++ b/app/Http/Controllers/Web/HomeController.php @@ -0,0 +1,80 @@ +artworks = $artworks; + } + + public function index(Request $request) + { + $page_title = 'Skinbase - Photography, Skins & Wallpapers'; + $page_meta_description = 'Skinbase legacy home, rendered via Laravel.'; + $page_meta_keywords = 'wallpapers, skins, photography, community'; + + $featuredResult = $this->artworks->getFeaturedArtworks(null, 39); + if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) { + $featured = $featuredResult->getCollection()->first(); + } elseif (is_array($featuredResult)) { + $featured = $featuredResult[0] ?? null; + } else { + $featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult; + } + + $memberFeatured = $featured; + + $latestUploads = $this->artworks->getLatestArtworks(20); + + // Forum news (root forum section id 2876) + $forumNews = DB::table('forum_topics as t1') + ->leftJoin('users as u', 't1.user_id', '=', 'u.user_id') + ->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview') + ->where('t1.root_id', 2876) + ->where('t1.privilege', '<', 4) + ->orderBy('t1.post_date', 'desc') + ->limit(8) + ->get(); + + // Our news (latest site news) + $ourNews = DB::table('news as t1') + ->join('news_categories as c', 't1.category_id', '=', 'c.category_id') + ->join('users as u', 't1.user_id', '=', 'u.user_id') + ->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments') + ->orderBy('t1.create_date', 'desc') + ->limit(5) + ->get(); + + // Latest forum activity (exclude rootless and news root) + $latestForumActivity = DB::table('forum_topics as t1') + ->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') + ->where('t1.root_id', '<>', 0) + ->where('t1.root_id', '<>', 2876) + ->where('t1.privilege', '<', 4) + ->orderBy('t1.last_update', 'desc') + ->orderBy('t1.post_date', 'desc') + ->limit(10) + ->get(); + + return view('web.home', compact( + 'page_title', + 'page_meta_description', + 'page_meta_keywords', + 'featured', + 'memberFeatured', + 'latestUploads', + 'forumNews', + 'ourNews', + 'latestForumActivity' + )); + } +} diff --git a/app/Http/Requests/AvatarUploadRequest.php b/app/Http/Requests/AvatarUploadRequest.php new file mode 100644 index 00000000..cbc1e2c3 --- /dev/null +++ b/app/Http/Requests/AvatarUploadRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'avatar' => [ + 'required', + 'file', + 'image', + 'max:2048', + 'mimes:jpg,jpeg,png,webp', + 'mimetypes:image/jpeg,image/png,image/webp', + ], + ]; + } +} diff --git a/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php b/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php index 186d8054..ade76dfb 100644 --- a/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php +++ b/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php @@ -40,7 +40,7 @@ final class ManageArtworkUpdateRequest extends FormRequest public function rules(): array { return [ - 'name' => 'required|string|max:255', + 'title' => 'required|string|max:255', 'section' => 'nullable|integer', 'description' => 'nullable|string', 'artwork' => 'nullable|file|image', diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 588d90dd..d4815405 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -37,7 +37,7 @@ class ProfileUpdateRequest extends FormRequest '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 fc76f6cf..d6799291 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; // 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 @@ class User extends Authenticatable 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 46c06ce4..7bb3de69 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -4,7 +4,7 @@ namespace App\Models; 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 @@ class UserProfile extends Model 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 1155fb19..8a4f9edd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,7 +35,7 @@ class AppServiceProvider extends ServiceProvider // 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 @@ class AppServiceProvider extends ServiceProvider 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 1430ab98..643dddd1 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,234 @@ 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); + } + + $this->quality = (int) config('avatars.quality', 85); + + try { + $this->manager = extension_loaded('gd') + ? new ImageManager(new GdDriver()) + : new ImageManager(new ImagickDriver()); + } catch (\Throwable $e) { + logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage()); + $this->manager = null; } } - /** - * 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->assertImageManagerAvailable(); + $this->assertStorageIsAllowed(); + $this->assertSecureImageUpload($file); + + $binary = file_get_contents($file->getRealPath()); + if ($binary === false || $binary === '') { + throw new RuntimeException('Uploaded avatar file is empty or unreadable.'); } - // Load image and re-encode to webp after validating - try { - $img = Image::make($file->getRealPath()); - } 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); - } - - $hash = sha1($originalData); - $mime = 'image/webp'; - - // 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, - ]); - } - - 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); + $image = $this->normalizeImage($image); + + $diskName = (string) config('avatars.disk', 's3'); + $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 (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, - ]); + if ($hashSeed === '') { + throw new RuntimeException('Avatar processing failed to generate a hash seed.'); } + $hash = hash('sha256', $hashSeed); + $this->updateProfileMetadata($userId, $hash); + return $hash; } -} -/** - * 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 normalizeImage($image) + { + try { + $core = $image->getCore(); + $isImagickCore = is_object($core) && strtolower(get_class($core)) === 'imagick'; + if ($isImagickCore) { + try { + $core->stripImage(); + } catch (\Throwable $_) { + } + + try { + $colorSpaceRgb = defined('\\Imagick::COLORSPACE_RGB') ? constant('\\Imagick::COLORSPACE_RGB') : null; + $colorSpaceSRgb = defined('\\Imagick::COLORSPACE_SRGB') ? constant('\\Imagick::COLORSPACE_SRGB') : null; + if (is_int($colorSpaceRgb)) { + $core->setImageColorspace($colorSpaceRgb); + } elseif (is_int($colorSpaceSRgb)) { + $core->setImageColorspace($colorSpaceSRgb); + } + } catch (\Throwable $_) { + } + + try { + $alphaRemove = defined('\\Imagick::ALPHACHANNEL_REMOVE') ? constant('\\Imagick::ALPHACHANNEL_REMOVE') : null; + if (is_int($alphaRemove)) { + $core->setImageAlphaChannel($alphaRemove); + } + } catch (\Throwable $_) { + } + + try { + $core->setBackgroundColor('white'); + $layerFlatten = defined('\\Imagick::LAYERMETHOD_FLATTEN') ? constant('\\Imagick::LAYERMETHOD_FLATTEN') : null; + $flattened = is_int($layerFlatten) ? $core->mergeImageLayers($layerFlatten) : null; + if (is_object($flattened) && strtolower(get_class($flattened)) === 'imagick') { + $core->clear(); + $core->destroy(); + $image = $this->manager->read((string) $flattened->getImageBlob()); + } + } catch (\Throwable $_) { + } + + return $image; + } + + $isGdCore = is_resource($core) || (is_object($core) && strtolower(get_class($core)) === 'gdimage'); + if ($isGdCore) { + $width = imagesx($core); + $height = imagesy($core); + if ($width > 0 && $height > 0) { + $flattened = imagecreatetruecolor($width, $height); + if ($flattened !== false) { + $white = imagecolorallocate($flattened, 255, 255, 255); + imagefilledrectangle($flattened, 0, 0, $width, $height, $white); + imagecopy($flattened, $core, 0, 0, 0, 0, $width, $height); + + ob_start(); + imagepng($flattened); + $pngBinary = (string) ob_get_clean(); + imagedestroy($flattened); + + if ($pngBinary !== '') { + return $this->manager->read($pngBinary); + } + } + } + } + } catch (\Throwable $_) { + } + + return $image; + } + + 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(), + ] + ); + } + + private function assertImageManagerAvailable(): void + { + if ($this->manager !== null) { + return; + } + + throw new RuntimeException('Avatar image processing is not available on this environment.'); + } + + 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/app/Support/AvatarUrl.php b/app/Support/AvatarUrl.php new file mode 100644 index 00000000..2b460415 --- /dev/null +++ b/app/Support/AvatarUrl.php @@ -0,0 +1,50 @@ +where('user_id', $userId) + ->value('avatar_hash'); + } catch (\Throwable $e) { + $value = null; + } + + self::$hashCache[$userId] = $value ? (string) $value : null; + + return self::$hashCache[$userId]; + } +} diff --git a/avatar_patch.diff b/avatar_patch.diff new file mode 100644 index 00000000..8dcc5337 --- /dev/null +++ b/avatar_patch.diff @@ -0,0 +1,1185 @@ +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 @@ + +
-@endsection +@endsection \ No newline at end of file diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php index 0b909507..bae1a020 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/_legacy.blade.php b/resources/views/layouts/_legacy.blade.php new file mode 100644 index 00000000..bc5a849b --- /dev/null +++ b/resources/views/layouts/_legacy.blade.php @@ -0,0 +1,50 @@ + + + + {{ $page_title ?? 'Skinbase' }} + + + + + + @isset($page_canonical) + + @endisset + + + + + @vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js']) + @stack('head') + + + +
+@include('layouts.nova.toolbar') + +
+
+ @hasSection('sidebar') +
+
+ @yield('content') +
+ +
+ @else +
+ @yield('content') +
+ @endif +
+
+ +@include('layouts.nova.footer') + +@stack('toolbar') +@stack('scripts') + + + diff --git a/resources/views/layouts/legacy.blade.php b/resources/views/layouts/legacy.blade.php deleted file mode 100644 index 206576a6..00000000 --- a/resources/views/layouts/legacy.blade.php +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - {{ $page_title ?? 'Skinbase' }} - - - - - - - - - - - - - - - @stack('head') - - -
- @include('legacy.toolbar') -
-
-
- @yield('content') -
- - {{-- Right sidebar placeholder (legacy layout) --}} -
- @yield('sidebar') -
- -
-
-
- - {{-- Toolbar placeholder --}} - @stack('toolbar') - - - - - - - - - - - - - @stack('scripts') - - - diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php index 3833458b..83c83ade 100644 --- a/resources/views/layouts/nova/toolbar.blade.php +++ b/resources/views/layouts/nova/toolbar.blade.php @@ -1,213 +1,240 @@
-
- - - - - - Skinbase.org - Skinbase.org - - - - + + +
+
+ + +
-
-
- - - + - -
-
- - -
+ + + + + + +
+ @else + + + @endauth
- - @auth - - - @else - - - @endauth -
diff --git a/resources/views/legacy/_artwork_card.blade.php b/resources/views/legacy/_artwork_card.blade.php index 1d7d1fa7..98dbd6e5 100644 --- a/resources/views/legacy/_artwork_card.blade.php +++ b/resources/views/legacy/_artwork_card.blade.php @@ -1,115 +1,2 @@ -@php - // If a Collection or array was passed accidentally, pick the first item. - if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) { - $first = null; - if (is_array($art)) { - $first = reset($art); - } elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) { - $first = $art->first(); - } - if ($first) { - $art = $first; - } - } - - $title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork')); - $author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase')); - $category = trim((string) ($art->category_name ?? $art->category ?? 'General')); - $license = trim((string) ($art->license ?? 'Standard')); - $resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : ''))); - // Safe integer extractor: handle numeric, arrays, Collections, or relations - $safeInt = function ($v, $fallback = 0) { - if (is_numeric($v)) return (int) $v; - if (is_array($v)) return count($v); - if (is_object($v)) { - // Eloquent Collections implement Countable and have count() - if (method_exists($v, 'count')) return (int) $v->count(); - if ($v instanceof Countable) return (int) count($v); - } - return (int) $fallback; - }; - - $likes = $safeInt($art->likes ?? $art->favourites ?? 0); - $downloads = $safeInt($art->downloads ?? $art->downloaded ?? 0); - - $img_src = (string) ($art->thumb ?? $art->thumbnail_url ?? '/images/placeholder.jpg'); - $img_srcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $img_src); - $img_avif_srcset = (string) ($art->thumb_avif_srcset ?? $img_srcset); - $img_webp_srcset = (string) ($art->thumb_webp_srcset ?? $img_srcset); - - // Width/height may be stored directly or as a related object; handle safely - $resolveDimension = function ($val, $fallback) { - if (is_numeric($val)) return (int) $val; - if (is_array($val)) { - $v = reset($val); - return is_numeric($v) ? (int) $v : (int) $fallback; - } - if (is_object($val)) { - if (method_exists($val, 'first')) { - $f = $val->first(); - if (is_object($f) && isset($f->width)) return (int) ($f->width ?: $fallback); - if (is_object($f) && isset($f->height)) return (int) ($f->height ?: $fallback); - } - // Try numeric cast otherwise - if (isset($val->width)) return (int) $val->width; - if (isset($val->height)) return (int) $val->height; - } - return (int) $fallback; - }; - - $img_width = max(1, $resolveDimension($art->width ?? null, 800)); - $img_height = max(1, $resolveDimension($art->height ?? null, 600)); - - $contentUrl = $img_src; - $cardUrl = (string) ($art->url ?? '#'); -@endphp - - +{{-- Shim for legacy artwork card includes. Points to new web partial. --}} +@include('web.partials._artwork_card') diff --git a/resources/views/legacy/forum/posts.blade.php b/resources/views/legacy/forum/posts.blade.php index d497a798..28e9f66f 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/interview.blade.php b/resources/views/legacy/interview.blade.php index af1364fe..2bb59b65 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 a9650a42..b78d76ad 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 ed99954f..2e33899c 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 28b7921e..7066d119 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 13fd2c60..c91f446c 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 7c875833..0d87945c 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 87239b6d..f3e9453c 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 75f08fdb..855bd955 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 5dcbf970..0cefc747 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 @@