Upload beautify

This commit is contained in:
2026-02-17 17:14:43 +01:00
parent b053c0cc48
commit 41287914aa
106 changed files with 4948 additions and 906 deletions

View File

@@ -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;
}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\AvatarService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AvatarController
{
protected $service;
public function __construct(AvatarService $service)
{
$this->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);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ChatController extends Controller
{
public function index(Request $request)
{
$page_title = 'Online Chat';
$store = $request->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'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->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);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InterviewController extends Controller
{
public function show(Request $request, $id, $slug = null)
{
$id = (int) $id;
if ($request->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,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class LatestCommentsController extends Controller
{
public function index(Request $request)
{
$hits = 20;
$query = ArtworkComment::with(['user', 'artwork'])
->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'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
class LatestController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->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',
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class NewsController extends Controller
{
public function show(Request $request, $id, $slug = null)
{
$id = (int) $id;
try {
$news = DB::table('news as t1')
->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'));
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Http\Requests\Manage\ManageArtworkEditRequest;
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
@@ -16,7 +17,6 @@ class ManageController extends Controller
$userId = $request->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);
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InterviewController extends Controller
{
public function show(Request $request, $id, $slug = null)
{
$id = (int) $id;
if ($request->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,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InterviewsController extends Controller
{
public function index(Request $request)
{
try {
$interviews = DB::table('interviews AS t1')
->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'));
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,
];

View File

@@ -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;

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Misc;
use App\Http\Requests\AvatarUploadRequest;
use App\Services\AvatarService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use RuntimeException;
class AvatarController
{
protected $service;
public function __construct(AvatarService $service)
{
$this->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);
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService;
use App\Models\ContentType;
class PhotographyController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->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'));
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\AvatarUploadRequest;
use App\Services\AvatarService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use RuntimeException;
class AvatarController extends Controller
{
protected $service;
public function __construct(AvatarService $service)
{
$this->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);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuddiesController extends Controller
{
public function index(Request $request)
{
$user = $request->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'));
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\UserFavorite;
class FavouritesController extends Controller
{
public function index(Request $request, $userId = null, $username = null)
{
$userId = $userId ? (int)$userId : ($request->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');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Str;
class MembersController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->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'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MonthlyCommentatorsController extends Controller
{
public function index(Request $request)
{
$hits = 30;
$page = max(1, (int) $request->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'));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MyBuddiesController extends Controller
{
public function index(Request $request)
{
$user = $request->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');
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -13,9 +14,6 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
@@ -23,25 +21,18 @@ class ProfileController extends Controller
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
{
$user = $request->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([

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ReceivedCommentsController extends Controller
{
public function index(Request $request)
{
$user = $request->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,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class StatisticsController extends Controller
{
public function index(Request $request): View
{
$userId = $request->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',
]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkDownload;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Carbon\Carbon;
class TodayDownloadsController extends Controller
{
public function index(Request $request)
{
$hits = 30;
$today = Carbon::now()->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]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TodayInHistoryController extends Controller
{
public function index(Request $request)
{
$hits = 39;
try {
$base = DB::table('featured_works as t0')
->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',
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\User;
class TopAuthorsController extends Controller
{
public function index(Request $request)
{
$perPage = 20;
$metric = strtolower($request->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'));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Services\LegacyService;
class TopFavouritesController extends Controller
{
public function index(Request $request)
{
$hits = 21;
$page = max(1, (int) $request->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]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index(Request $request)
{
$user = $request->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,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Facades\DB;
class ArtController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->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);
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -9,16 +10,13 @@ class BrowseCategoriesController extends Controller
{
public function index(Request $request)
{
// Use Eloquent models for canonical category URLs and grouping
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->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);
}

View File

@@ -1,14 +1,15 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Web;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use App\Http\Controllers\ArtworkController as ArtworkControllerAlias;
class BrowseGalleryController extends Controller
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
@@ -121,7 +122,7 @@ class BrowseGalleryController extends Controller
public function showArtwork(Request $request, string $contentTypeSlug, string $categoryPath, string $artwork)
{
return app(ArtworkController::class)->show(
return app(\App\Http\Controllers\ArtController::class)->show(
$request,
strtolower($contentTypeSlug),
trim($categoryPath, '/'),

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CategoryController extends Controller
{
protected ArtworkService $artworkService;
public function __construct(ArtworkService $artworkService)
{
$this->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);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
class DailyUploadsController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->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',
];
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
class FeaturedArtworksController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->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,
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\Schema;
class GalleryController extends Controller
{
public function show(Request $request, $userId, $username = null)
{
$user = User::find((int)$userId);
if (! $user) {
abort(404);
}
$page = max(1, (int) $request->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,
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use Illuminate\Support\Facades\DB;
class HomeController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->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'
));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AvatarUploadRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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',
],
];
}
}

View File

@@ -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',

View File

@@ -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'],
];

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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'));
});
}

View File

@@ -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.');
}
}
}

50
app/Support/AvatarUrl.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
class AvatarUrl
{
private static array $hashCache = [];
public static function forUser(int $userId, ?string $hash = null, int $size = 128): string
{
if ($userId <= 0) {
return self::default();
}
$avatarHash = $hash ?: self::resolveHash($userId);
if (!$avatarHash) {
return self::default();
}
$base = rtrim((string) config('cdn.avatar_url', 'https://file.skinbase.org'), '/');
return sprintf('%s/avatars/%d/%d.webp?v=%s', $base, $userId, $size, $avatarHash);
}
public static function default(): string
{
return asset('img/default-avatar.webp');
}
private static function resolveHash(int $userId): ?string
{
if (array_key_exists($userId, self::$hashCache)) {
return self::$hashCache[$userId];
}
try {
$value = DB::table('user_profiles')
->where('user_id', $userId)
->value('avatar_hash');
} catch (\Throwable $e) {
$value = null;
}
self::$hashCache[$userId] = $value ? (string) $value : null;
return self::$hashCache[$userId];
}
}