Current state

This commit is contained in:
2026-02-07 08:23:18 +01:00
commit 0a4372c40d
22479 changed files with 1553543 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkFeature;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
/**
* ArtworkService
*
* Business logic for retrieving artworks. Controllers should remain thin and
* delegate to this service. This service never returns JSON or accesses
* the request() helper directly.
*/
class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
/**
* Fetch a single public artwork by slug.
* Applies visibility rules (public + approved + not-deleted).
*
* @param string $slug
* @return Artwork
* @throws ModelNotFoundException
*/
public function getPublicArtworkBySlug(string $slug): Artwork
{
$key = 'artwork:' . $slug;
$artwork = Cache::remember($key, $this->cacheTtl, function () use ($slug) {
$a = Artwork::where('slug', $slug)
->public()
->published()
->first();
if (! $a) {
return null;
}
// Load lightweight relations for presentation; do NOT eager-load stats here.
$a->load(['translations', 'categories']);
return $a;
});
if (! $artwork) {
$e = new ModelNotFoundException();
$e->setModel(Artwork::class, [$slug]);
throw $e;
}
return $artwork;
}
/**
* Clear artwork cache by model instance.
*/
public function clearArtworkCache(Artwork $artwork): void
{
$this->clearArtworkCacheBySlug($artwork->slug);
}
/**
* Clear artwork cache by slug.
*/
public function clearArtworkCacheBySlug(string $slug): void
{
Cache::forget('artwork:' . $slug);
}
/**
* Get artworks for a given category, applying visibility rules and cursor pagination.
* Returns a CursorPaginator so controllers/resources can render paginated feeds.
*
* @param Category $category
* @param int $perPage
* @return CursorPaginator
*/
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{
$query = Artwork::public()->published()
->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
})
->orderByDesc('published_at');
// Important: do NOT eager-load artwork_stats in listings
return $query->cursorPaginate($perPage);
}
/**
* Return the latest public artworks up to $limit.
*
* @param int $limit
* @return \Illuminate\Support\Collection|EloquentCollection
*/
public function getLatestArtworks(int $limit = 10): Collection
{
return Artwork::public()->published()
->orderByDesc('published_at')
->limit($limit)
->get();
}
/**
* Browse all public, approved, published artworks with pagination.
* Uses new authoritative tables only (no legacy joins) and eager-loads
* lightweight relations needed for presentation.
*/
public function browsePublicArtworks(int $perPage = 24): CursorPaginator
{
$query = Artwork::public()
->published()
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
// Use cursor pagination for high-load browse feeds (SEO handled via canonical URLs).
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks scoped to a content type slug using keyset pagination.
* Applies public + approved + published filters.
*/
public function getArtworksByContentType(string $slug, int $perPage): CursorPaginator
{
$contentType = ContentType::where('slug', strtolower($slug))->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
$query = Artwork::public()
->published()
->whereHas('categories', function ($q) use ($contentType) {
$q->where('categories.content_type_id', $contentType->id);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks for a category path (content type slug + nested category slugs).
* Uses slug-only resolution and keyset pagination.
*
* @param array<int, string> $slugs
*/
public function getArtworksByCategoryPath(array $slugs, int $perPage): CursorPaginator
{
if (empty($slugs)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class);
throw $e;
}
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = ContentType::where('slug', $contentTypeSlug)->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$contentTypeSlug]);
throw $e;
}
if (empty($parts)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, []);
throw $e;
}
// Resolve the category path from roots downward within the content type.
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', array_shift($parts))
->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
foreach ($parts as $slug) {
$current = $current->children()->where('slug', $slug)->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
}
$query = Artwork::public()
->published()
->whereHas('categories', function ($q) use ($current) {
$q->where('categories.id', $current->id);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
return $query->cursorPaginate($perPage);
}
/**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters.
*/
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
{
$query = Artwork::query()
->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->public()
->published()
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
])
->orderByDesc('af.featured_at')
->orderByDesc('artworks.published_at');
return $query->paginate($perPage)->withQueryString();
}
/**
* Get artworks belonging to a specific user.
* If the requester is the owner, return all non-deleted artworks for that user.
* Public visitors only see public + approved + published artworks.
*
* @param int $userId
* @param bool $isOwner
* @param int $perPage
* @return CursorPaginator
*/
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): CursorPaginator
{
$query = Artwork::where('user_id', $userId)
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
if (! $isOwner) {
// Apply public visibility constraints for non-owners
$query->public()->published();
} else {
// Owner: include all non-deleted items (do not force published/approved)
$query->whereNull('deleted_at');
}
return $query->cursorPaginate($perPage);
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* ArtworkStatsService
*
* Responsibilities:
* - Increment views and downloads using DB transactions
* - Optionally defer increments into Redis for async processing
* - Provide a processor to drain queued deltas (job-friendly)
*/
class ArtworkStatsService
{
protected string $redisKey = 'artwork_stats:deltas';
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
$this->applyDelta($artworkId, ['views' => $by]);
}
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
/**
* Increment views using an Artwork model. Preferred API-first signature.
*/
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* This method is safe to call from jobs or synchronously.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
public function applyDelta(int $artworkId, array $deltas): void
{
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists. Insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
/**
* Increment downloads using an Artwork model. Preferred API-first signature.
*/
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fallback to immediate apply to avoid data loss
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
public function processPendingFromRedis(int $max = 1000): int
{
if (! $this->redisAvailable()) {
return 0;
}
$processed = 0;
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
return $processed;
}
protected function redisAvailable(): bool
{
try {
// Redis facade may throw if not configured
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,655 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
/**
* @deprecated LegacyService contains helpers to render legacy pages and should be
* migrated to new services. Keep in place until legacy controllers/views
* are refactored. Instantiating the service will emit a deprecation log.
*/
class LegacyService
{
public function __construct()
{
Log::warning('App\Services\LegacyService is deprecated. Please migrate callers to modern services.');
}
public function featured(): array
{
$featured = null;
$memberFeatured = null;
try {
$featured = DB::connection('legacy')->table('featured_works as fw')
->leftJoin('wallz as w', 'fw.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'u.uname', 'fw.post_date')
->orderByDesc('fw.post_date')
->first();
$memberFeatured = DB::connection('legacy')->table('users_opinions as o')
->leftJoin('wallz as w', 'o.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select(DB::raw('COUNT(*) AS votes'), 'w.id', 'w.name', 'w.picture', 'u.uname')
->whereRaw('o.post_date > SUBDATE(CURRENT_DATE(), INTERVAL 30 DAY)')
->where('o.score', 4)
->groupBy('o.artwork_id', 'w.id', 'w.name', 'w.picture', 'u.uname')
->orderByDesc('votes')
->limit(1)
->first();
} catch (\Throwable $e) {
// fail soft
}
if (!$featured) {
$featured = (object) [
'id' => 0,
'name' => 'Featured Artwork',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
];
}
if (!$memberFeatured) {
$memberFeatured = (object) [
'id' => 0,
'name' => 'Members Pick',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
'votes' => 0,
];
}
return [$featured, $memberFeatured];
}
public function latestUploads(): array
{
$uploads = [];
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
if (File::exists($cachePath)) {
$json = File::get($cachePath);
$uploads = json_decode($json, true) ?: [];
}
if (empty($uploads)) {
try {
$uploads = DB::connection('legacy')->table('wallz as w')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->where('w.approved', 1)
->orderByDesc('w.datum')
->limit(20)
->get()
->map(function ($row) {
return [
'id' => $row->id,
'name' => $row->name,
'picture' => $row->picture,
'uname' => $row->uname,
'category_name' => $row->category_name ?? '',
];
})
->toArray();
} catch (\Throwable $e) {
$uploads = [];
}
}
if (empty($uploads)) {
$uploads = [
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],
];
}
return $uploads;
}
public function forumNews(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select(
't1.topic_id',
't1.topic',
't1.views',
't1.post_date',
't1.preview',
't2.uname'
)
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
->limit(8)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function ourNews(): array
{
try {
return DB::connection('legacy')->table('news as t1')
->join('news_categories as t2', 't1.category_id', '=', 't2.category_id')
->join('users as t3', 't1.user_id', '=', 't3.user_id')
->select(
't1.news_id',
't1.headline',
't1.picture',
't1.preview',
't1.create_date',
't1.views',
't2.category_name',
't3.uname',
DB::raw('(SELECT COUNT(*) FROM news_comment WHERE news_id = t1.news_id) AS num_comments')
)
->orderByDesc('t1.create_date')
->limit(5)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function latestForumActivity(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->select(
't1.topic_id',
't1.topic',
DB::raw('(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)
->orderByDesc('t1.last_update')
->orderByDesc('t1.post_date')
->limit(10)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function browseGallery(int $perPage = 50)
{
try {
return DB::connection('legacy')->table('wallz as w')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'w.category', 'w.datum', 'c.category_name', 'u.uname')
->where('w.approved', 1)
->where('w.public', 'Y')
->orderByDesc('w.datum')
->paginate($perPage)
->withQueryString();
} catch (\Throwable $e) {
return null;
}
}
public function categoryPage(string $group, ?string $slug = null, ?int $id = null)
{
$group = \Illuminate\Support\Str::title($group);
$defaults = [
'Skins' => 1,
'Wallpapers' => 2,
'Photography' => 3,
'Other' => 4,
];
if (!$id && $slug && ctype_digit($slug)) {
$id = (int) $slug;
}
$id = $id ?: ($defaults[$group] ?? null);
if (!$id || $id < 1) {
return null;
}
try {
$category = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description', 'rootid', 'section_id')
->where('category_id', $id)
->first();
} catch (\Throwable $e) {
$category = null;
}
if (! $category) {
return null;
}
$perPage = 40;
try {
$base = DB::connection('legacy')->table('wallz as t1')
->select('t1.id', 't1.name', 't1.picture', 't3.uname', 't1.category', 't2.category_name')
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
->leftJoin('users as t3', 't1.user_id', '=', 't3.user_id')
->where('t1.approved', 1)
->where(function ($q) use ($id, $category) {
$q->where('t1.category', (int) $id);
if ($category->rootid > 0) {
$q->orWhere('t1.rootid', (int) $id);
}
})
->orderByDesc('t1.datum');
$artworks = $base->paginate($perPage)->withQueryString();
if ($artworks && method_exists($artworks, 'getCollection')) {
$artworks->getCollection()->transform(function ($row) {
$row->gid_num = ((int) ($row->category ?? 0) % 5) * 5;
if (!empty($row->picture)) {
$ext = self::fileExtension($row->picture);
$encoded = self::encode($row->id);
$row->ext = $ext;
$row->encoded = $encoded;
// Prefer new files.skinbase.org when possible
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'];
}
} else {
$row->ext = null;
$row->encoded = null;
$row->thumb_url = '/gfx/sb_join.jpg';
$row->thumb_srcset = null;
}
return $row;
});
}
} catch (\Throwable $e) {
$artworks = null;
}
try {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
if ($subcategories->isEmpty() && $category->rootid) {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $category->rootid)
->orderBy('category_name')
->get();
}
if ($subcategories->isEmpty()) {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
}
} catch (\Throwable $e) {
try {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
} catch (\Throwable $e2) {
$subcategories = collect();
}
}
$page_title = $group;
$page_meta_description = $group . ' artworks on Skinbase';
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, skins';
return [
'group' => $group,
'category' => $category,
'artworks' => $artworks,
'subcategories' => $subcategories,
'page_title' => $page_title,
'page_meta_description' => $page_meta_description,
'page_meta_keywords' => $page_meta_keywords,
];
}
public function browseCategories()
{
try {
$categories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
if ($categories->isEmpty()) {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
}
} catch (\Throwable $e) {
try {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
} catch (\Throwable $e2) {
$categories = collect();
}
}
$subgroups = collect();
if ($categories->isNotEmpty()) {
$ids = $categories->pluck('category_id')->unique()->values()->all();
try {
$subs = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
if ($subs->isEmpty()) {
$subs = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
}
$subgroups = $subs->groupBy('section_id');
} catch (\Throwable $e) {
$subgroups = collect();
}
}
return [
'categories' => $categories,
'subgroups' => $subgroups,
'page_title' => 'Browse Categories',
'page_meta_description' => 'Browse categories across Photography, Wallpapers, Skins and more on Skinbase.',
'page_meta_keywords' => 'categories, photography, wallpapers, skins, browse',
];
}
public function forumIndex()
{
try {
$topics = DB::connection('legacy')->table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return [
'topics' => $topics,
'page_title' => 'Forum',
'page_meta_description' => 'Skinbase forum threads.',
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
];
}
public function forumTopic(int $topic_id, int $page = 1)
{
try {
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (! $topic) {
return null;
}
try {
$subtopics = DB::connection('legacy')->table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = null;
}
if ($subtopics && $subtopics->total() > 0) {
return [
'type' => 'subtopics',
'topic' => $topic,
'subtopics' => $subtopics,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
$sort = strtolower(request()->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
if (! $posts || $posts->total() === 0) {
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
}
// Ensure $posts is always a LengthAwarePaginator so views can iterate and render pagination safely
if (! $posts) {
$currentPage = max(1, (int) request()->query('page', $page));
$items = collect();
$posts = new LengthAwarePaginator($items, 0, 50, $currentPage, [
'path' => Paginator::resolveCurrentPath(),
]);
}
return [
'type' => 'posts',
'topic' => $topic,
'posts' => $posts,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
/**
* Fetch a single artwork by id with author and category
* Returns null on failure.
*/
public function getArtwork(int $id)
{
try {
$row = DB::connection('legacy')->table('wallz as w')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->select('w.*', 'u.uname', 'c.category_name')
->where('w.id', $id)
->first();
} catch (\Throwable $e) {
$row = null;
}
if (! $row) {
return null;
}
// compute thumbnail/zoom paths similar to legacy code
$nid = (int) ($row->id / 100);
$nid_new = (int) ($row->id / 1000);
$encoded = self::encode($row->id);
$ext = self::fileExtension($row->picture ?? 'jpg');
$appUrl = rtrim(config('app.url', ''), '/');
$shot_name = $appUrl . '/files/archive/shots/' . $nid . '/' . ($row->picture ?? '');
$zoom_name = $appUrl . '/files/archive/zoom/' . $nid . '/' . ($row->picture ?? '');
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
// Prefer new CDN when possible
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
}
// additional stats (best-effort)
try {
$num_downloads = DB::connection('legacy')->table('artworks_downloads')
->where('date', DB::raw('CURRENT_DATE'))
->where('artwork_id', $row->id)
->count();
} catch (\Throwable $e) {
$num_downloads = 0;
}
try {
$monthly_downloads = DB::connection('legacy')->table('monthly_downloads')
->where('fname', $row->id)
->count();
} catch (\Throwable $e) {
$monthly_downloads = 0;
}
try {
$num_comments = DB::connection('legacy')->table('artworks_comments')
->where('name', $row->id)
->where('author', '<>', '')
->count();
} catch (\Throwable $e) {
$num_comments = 0;
}
try {
$num_favourites = DB::connection('legacy')->table('favourites')
->where('artwork_id', $row->id)
->count();
} catch (\Throwable $e) {
$num_favourites = 0;
}
try {
$featured = DB::connection('legacy')->table('featured_works')
->where('rootid', $row->rootid ?? 0)
->where('artwork_id', $row->id)
->orderByDesc('type')
->select('type', 'post_date')
->first();
$featured_type = $featured->type ?? 0;
$featured_date = $featured->post_date ?? null;
} catch (\Throwable $e) {
$featured_type = 0;
$featured_date = null;
}
$page_title = $row->name ?? 'Artwork';
$page_meta_description = strip_tags($row->description ?? ($row->preview ?? ''));
$page_meta_keywords = trim(($row->category_name ?? '') . ', artwork');
return [
'artwork' => $row,
'thumb_file' => $thumb_file,
'thumb_file_300' => $thumb_file_300,
'thumb_600' => $thumb_600,
'shot_name' => $shot_name,
'zoom_name' => $zoom_name,
'num_downloads' => $num_downloads,
'monthly_downloads' => $monthly_downloads,
'num_comments' => $num_comments,
'num_favourites' => $num_favourites,
'featured_type' => $featured_type,
'featured_date' => $featured_date,
'page_title' => $page_title,
'page_meta_description' => $page_meta_description,
'page_meta_keywords' => $page_meta_keywords,
];
}
public static function encode($val, $base = 62, $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
$str = '';
if ($val < 0) return $str;
do {
$i = $val % $base;
$str = $chars[$i] . $str;
$val = ($val - $i) / $base;
} while ($val > 0);
return $str;
}
private static function fileExtension($filename) {
$parts = pathinfo($filename);
return $parts['extension'] ?? 'jpg';
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Services\ThumbnailService;
class ThumbnailPresenter
{
/**
* Present thumbnail data for an item which may be a model or an array.
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
*/
public static function present($item, string $size = 'md'): array
{
$uext = 'jpg';
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
$id = null;
$title = '';
if ($isEloquent) {
$id = $item->id ?? null;
$title = $item->name ?? '';
$url = $item->thumb_url ?? $item->thumb ?? '';
$srcset = $item->thumb_srcset ?? null;
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
}
// If it's an object but not an Eloquent model (e.g. stdClass row), cast to array
if (is_object($item)) {
$item = (array) $item;
}
$id = $item['id'] ?? null;
$title = $item['name'] ?? '';
// If array contains direct hash/thumb_ext, use CDN fromHash
$hash = $item['hash'] ?? null;
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext);
if (!empty($hash) && !empty($thumbExt)) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
$srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
}
// Fallback: ask ThumbnailService to resolve by id or file path
$url = ThumbnailService::url(null, $id, $uext, 6);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => null];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
class ThumbnailService
{
protected const CDN_HOST = 'http://files.skinbase.org';
protected const VALID_SIZES = ['sm','md','lg','xl'];
protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
];
/**
* Build a thumbnail URL from a filePath/hash/id and ext.
* Accepts either a direct hash string in $filePath, or an $id + $ext pair.
* Legacy size codes (4 -> sm, others -> md) are supported.
*/
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, $size = 6): string
{
// If $filePath seems to be a content hash and $ext is provided, build directly
if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) {
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
return self::fromHash($filePath, $ext, $sizeKey) ?: '';
}
// Resolve by id when provided
if ($id !== null) {
try {
$artClass = '\\App\\Models\\Artwork';
if (class_exists($artClass)) {
$art = $artClass::where('id', $id)->orWhere('legacy_id', $id)->first();
if ($art) {
$hash = $art->hash ?? null;
$extToUse = $ext ?? ($art->thumb_ext ?? null);
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
if (!empty($hash) && !empty($extToUse)) {
return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
}
}
}
} catch (\Throwable $e) {
// fallthrough to storage/filePath fallback
}
}
// Fallback to Storage::url or return provided path
if (!empty($filePath)) {
try {
return Storage::url($filePath);
} catch (\Throwable $e) {
return $filePath;
}
}
return '';
}
/**
* Build CDN URL from hash and extension.
*/
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{
if (empty($hash) || empty($ext)) return null;
$sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md';
$h = $hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
return sprintf('%s/%s/%s/%s/%s.%s', rtrim(self::CDN_HOST, '/'), $sizeKey, $h1, $h2, $h, $ext);
}
/**
* Build srcset using sm and md sizes for legacy layouts.
*/
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{
$a = self::fromHash($hash, $ext, 'sm');
$b = self::fromHash($hash, $ext, 'md');
if (!$a || !$b) return null;
return $a . ' 320w, ' . $b . ' 600w';
}
}