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

35
app/Banner.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App;
class Banner
{
public static function ShowResponsiveAd()
{
echo '<div class="responsive_ad">';
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
echo '</div>';
}
public static function ShowBanner300x250()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:300px;height:250px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="7579263359"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBanner728x90()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="1234567890"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBannerGoogle300x250()
{
// alias to ShowBanner300x250 for compatibility
self::ShowBanner300x250();
}
}

100
app/Chat.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace App;
use Illuminate\Support\Facades\DB;
class Chat
{
public $username = "";
public $nickname = "";
public function Authenticate()
{
}
public function StoreMessage($tekst)
{
$userId = $_SESSION['web_login']['user_id'] ?? null;
$username = $_SESSION['web_login']['username'] ?? null;
if (empty($userId) || empty($username) || empty($tekst)) {
return;
}
$last = DB::connection('legacy')->table('chat')
->select('message')
->where('user_id', $userId)
->orderByDesc('chat_id')
->limit(1)
->first();
if (!$last || ($last->message ?? '') !== $tekst) {
DB::connection('legacy')->table('chat')->insert([
'time' => now(),
'sender' => $username,
'user_id' => $userId,
'message' => $tekst,
]);
}
}
public function UpdateChatFile($chat_file, $num_rows)
{
$output = "<ul>";
$chats = DB::connection('legacy')->table('chat')
->select('time', 'sender', 'message')
->orderByDesc('chat_id')
->limit((int)$num_rows ?: 8)
->get();
$x = 0;
foreach ($chats as $chat) {
$x++;
$add = ($x % 2 === 0) ? ' class="odd" ' : '';
$datetime = date("F jS @ H:i", strtotime($chat->time));
$message = wordwrap($chat->message, 20, " ", true);
$ime = wordwrap($chat->sender, 12, " ", true);
$output .= '<li ' . $add . '>&lt;';
$output .= '<a href="/profile.php?uname=' . rawurlencode($chat->sender) . '" title="' . htmlspecialchars($datetime, ENT_QUOTES, 'UTF-8') . ' GMT+1">';
$output .= htmlspecialchars($ime, ENT_QUOTES, 'UTF-8');
$output .= '</a>&gt; ';
$output .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$output .= '</li>';
}
$output .= '</ul>';
@file_put_contents(base_path($chat_file), $output);
}
public function ShowOnline()
{
echo '<div id="oboks" name="boks">Loading...</div>';
}
public function ShowChat($num_rows = 10, $username = null)
{
echo '<div id="chat_box" name="chat_box">Loading...</div>';
echo '<div class="row well">';
if (!empty($_SESSION['web_login']['status'])) {
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
echo '<div class="col-sm-10">';
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
echo '</div>';
echo '<div class="col-sm-2">';
echo '<button type="submit" class="btn btn-success">Say</button>';
echo '</div>';
echo '<input type="hidden" name="store_chat" value="true">';
echo '</form>';
} else {
echo '<div class="clear alert alert-danger">You should be logged in to join a chat!</div>';
}
echo '</div>';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportArtworkHashes extends Command
{
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
public function handle(): int
{
$file = $this->argument('file');
$path = base_path($file);
if (!is_readable($path)) {
$this->error("CSV file not readable: {$path}");
return 1;
}
$handle = fopen($path, 'r');
if (!$handle) {
$this->error('Unable to open CSV file');
return 1;
}
// Read header
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV appears empty');
fclose($handle);
return 1;
}
$start = (int) $this->option('start');
$limit = (int) $this->option('limit');
$rowIndex = 0;
$bar = null;
// Optionally count lines for progress bar
if ($limit === 0) {
// We'll not determine total to keep memory low
}
while (($row = fgetcsv($handle)) !== false) {
$rowIndex++;
if ($rowIndex <= $start) {
continue;
}
if ($limit > 0 && ($rowIndex - $start) > $limit) {
break;
}
// Expecting columns: id,hash,file_ext,thumb_ext
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
if (empty($id)) {
continue;
}
// Normalize null strings
if (strtoupper($hash) === 'NULL') $hash = null;
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
try {
DB::table('artworks')->where('id', $id)->limit(1)->update([
'hash' => $hash,
'file_ext' => $file_ext,
'thumb_ext' => $thumb_ext,
]);
} catch (\Throwable $e) {
// Log and continue on duplicate / other DB errors
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
continue;
}
if ($rowIndex % 500 === 0) {
$this->info("Processed {$rowIndex} rows");
}
}
fclose($handle);
$this->info('Import complete.');
return 0;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class ImportCategories extends Command
{
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
protected $description = 'Import categories from legacy CSV into categories table';
public function handle(): int
{
$path = base_path($this->argument('path'));
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$file = new \SplFileObject($path);
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
$header = null;
$rows = [];
foreach ($file as $index => $row) {
if ($row === [null] || $row === false) {
continue;
}
if ($index === 0) {
$header = $row;
continue;
}
// normalize row length
if (!is_array($row)) {
continue;
}
$rows[] = $row;
}
// build a map of rows by category id to allow ancestor lookups
$map = [];
foreach ($rows as $r) {
$rid = isset($r[0]) ? (int)$r[0] : null;
if ($rid) {
$map[$rid] = $r;
}
}
// Temporarily disable foreign key checks to allow inserting parents out of order
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// truncate categories table to start fresh
DB::table('categories')->truncate();
DB::beginTransaction();
try {
$this->info('Inserting categories...');
// helper to walk ancestor chain and find a valid content_type_id
$findAncestorContentType = function ($startRoot) use ($map) {
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$ancestor = $map[$cur];
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
if (!empty($candidate)) {
// verify the content type exists in DB
if (DB::table('content_types')->where('id', $candidate)->exists()) {
return $candidate;
}
}
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
}
return null;
};
// helper to collect ancestor chain rows for debugging dump
$collectAncestorChain = function ($startRoot) use ($map) {
$chain = [];
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$r = $map[$cur];
$chain[] = [
'id' => $cur,
'name' => $r[1] ?? null,
'section_id' => isset($r[7]) ? (int)$r[7] : null,
'rootid' => isset($r[8]) ? (int)$r[8] : null,
'raw' => $r,
];
$cur = isset($r[8]) ? (int)$r[8] : null;
}
return $chain;
};
foreach ($rows as $row) {
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
$origId = isset($row[0]) ? (int)$row[0] : null;
$name = isset($row[1]) ? trim($row[1]) : '';
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
$rootId = isset($row[8]) ? (int)$row[8] : null;
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
$parentId = null;
} else {
// If content_type_id == parent_id then parent_id should be NULL
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
}
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
if (!$ctExists) {
if (!empty($rootId)) {
$resolved = $findAncestorContentType($rootId);
if ($resolved) {
$contentTypeId = $resolved;
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
}
}
}
// after resolution, verify content_type exists
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
$dump = [
'row' => $row,
'resolved_root' => $rootId,
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
];
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
}
$slug = Str::slug($name) ?: 'cat-' . $origId;
// ensure uniqueness for slug within content_type
$existing = DB::table('categories')
->where('content_type_id', $contentTypeId)
->where('slug', $slug)
->exists();
if ($existing) {
$slug = $slug . '-' . $origId;
}
$now = now();
$data = [
'id' => $origId,
'content_type_id' => $contentTypeId,
'parent_id' => $parentId,
'name' => $name,
'slug' => $slug,
'description' => $description,
'image' => $image,
'is_active' => true,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
];
DB::table('categories')->insert($data);
$this->line("Inserted category {$origId}: {$name}");
}
DB::commit();
$this->info('Import completed.');
return 0;
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Import failed: ' . $e->getMessage());
return 1;
} finally {
// re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
*
* Usage:
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
*/
class ImportLegacyArtworks extends Command
{
protected $signature = 'skinbase:import-legacy-artworks
{--chunk=500 : chunk size for processing}
{--limit= : maximum number of legacy rows to import}
{--dry-run : do not persist any changes}
{--legacy-connection=legacy : name of legacy DB connection}
{--legacy-table=wallz : legacy artworks table name}
{--connected-table=connected : legacy artwork->category table}
';
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
private function coerceUnsignedInt(mixed $value, int $default = 0): int
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 1 : 0;
}
if (is_int($value)) {
return max(0, $value);
}
if (is_float($value)) {
return max(0, (int) $value);
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return $default;
}
if (is_numeric($trimmed)) {
return max(0, (int) $trimmed);
}
}
return $default;
}
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$stringValue = trim((string) $value);
return $stringValue !== '' ? $stringValue : $default;
}
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$connectedTable = $this->option('connected-table');
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
$processed = 0;
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
foreach ($rows as $row) {
if ($limit !== null && $processed >= $limit) {
return false; // stop chunking
}
$legacyId = $row->id ?? null;
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
$description = $row->description ?? $row->desc ?? null;
$slugBase = Str::slug(substr((string) $title, 0, 120));
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
$publishedAt = null;
if (! empty($row->datum)) {
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
} elseif (! empty($row->created_at)) {
$publishedAt = $row->created_at;
}
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
$fileName = null;
$filePath = '';
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
$fileName = trim((string) $rawFileName);
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
}
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
$mime = $row->mimetype ?? $row->mime ?? null;
if (empty($mime) && $fileName) {
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
$map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'zip' => 'application/zip',
'pdf' => 'application/pdf',
];
$mime = $map[$ext] ?? null;
}
if (empty($mime)) {
$mime = 'application/octet-stream';
}
$data = [
'id' => $row->id ?? null,
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
'user_id' => $row->user_id ?? 1,
'title' => (string) $title,
'slug' => (string) $slug,
'description' => $description,
'file_name' => $fileName,
// ensure non-null file_path to satisfy NOT NULL DB constraints
'file_path' => $filePath ?? '',
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
'mime_type' => $mime,
'width' => $row->width ?? null,
'height' => $row->height ?? null,
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
'published_at' => $publishedAt,
];
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
if ($dryRun) {
$processed++;
continue;
}
try {
$art = null;
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
// create artwork (guard against unique slug collisions)
$baseSlug = $data['slug'];
$attempt = 0;
$slug = $baseSlug;
while (Artwork::where('slug', $slug)->exists()) {
$attempt++;
$slug = $baseSlug . '-' . $attempt;
}
$data['slug'] = $slug;
// Preserve legacy primary ID if available and safe to do so.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
if (Artwork::where('id', $preserveId)->exists()) {
// Avoid overwriting an existing artwork with the same id.
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
}
$data['id'] = $preserveId;
}
// If we need to preserve the legacy primary id, perform a raw insert
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
if (! empty($data['id'])) {
$insert = $data;
$ts = date('Y-m-d H:i:s');
if (! array_key_exists('created_at', $insert)) {
$insert['created_at'] = $ts;
}
if (! array_key_exists('updated_at', $insert)) {
$insert['updated_at'] = $ts;
}
DB::table('artworks')->insert($insert);
$art = Artwork::find($insert['id']);
} else {
$art = Artwork::create($data);
}
// attach categories if connected table exists
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
$rows = DB::connection($legacyConn)->table($connectedTable)
->where(function ($q) use ($legacyId) {
$q->where('wallz_id', $legacyId)
->orWhere('art_id', $legacyId)
->orWhere('item_id', $legacyId);
})->get();
$categoryIds = [];
foreach ($rows as $r) {
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
if ($cid) {
// try to find matching Category in new DB by id or slug
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
$categoryIds[] = (int) $cid;
} else {
// maybe legacy stores slug
$cat = \App\Models\Category::where('slug', $cid)->first();
if ($cat) {
$categoryIds[] = $cat->id;
}
}
}
}
if (! empty($categoryIds)) {
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
}
}
});
// Post-insert verification: if we attempted to preserve the legacy id,
// confirm the row exists with that id. Log mapping if preservation failed.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
$exists = Artwork::where('id', $preserveId)->exists();
if (! $exists) {
// If $art was created but with a different id, log mapping for manual reconciliation
if ($art instanceof Artwork) {
Log::warning('Imported legacy artwork but failed to preserve id', [
'legacy_id' => $preserveId,
'created_id' => $art->id,
'slug' => $art->slug ?? null,
]);
} else {
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
}
}
}
$processed++;
} catch (Throwable $e) {
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
Log::error('ImportLegacyArtworks error', [
'legacy_id' => $legacyId,
'error' => $e->getMessage(),
'data' => $data ?? null,
'trace' => $e->getTraceAsString(),
]);
}
}
return null;
}, 'id');
$this->info('Import complete. Processed: ' . $processed);
return 0;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected array $usedUsernames = [];
protected array $usedEmails = [];
public function handle(): int
{
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
$chunk = (int) $this->option('chunk');
$imported = 0;
$skipped = 0;
if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE;
}
DB::connection('legacy')
->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
$ids = $rows->pluck('user_id')->all();
$stats = DB::connection('legacy')
->table('users_statistics')
->whereIn('user_id', $ids)
->get()
->keyBy('user_id');
foreach ($rows as $row) {
try {
$this->importRow($row, $stats[$row->user_id] ?? null);
$imported++;
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
}
}
}, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}");
return self::SUCCESS;
}
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$username = $this->uniqueUsername($baseUsername);
$email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null;
// Optionally force-reset every imported user's password to a secure random value.
if ($this->option('force-reset-all')) {
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
$passwordHash = Hash::make(Str::random(64));
} else {
// Force-reset known weak default passwords (e.g. "abc123").
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
$passwordHash = Hash::make(Str::random(64));
} else {
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
}
}
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => $passwordHash,
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null,
'created_at' => $row->joinDate ?: $now,
'updated_at' => $now,
]);
DB::table('user_profiles')->insert([
'user_id' => $legacyId,
'bio' => $row->about_me ?: $row->description ?: null,
'avatar' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null,
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'website' => $row->web ?: null,
'created_at' => $now,
'updated_at' => $now,
]);
if (!empty($row->web)) {
DB::table('user_social_links')->insert([
'user_id' => $legacyId,
'platform' => 'website',
'url' => $row->web,
'created_at' => $now,
'updated_at' => $now,
]);
}
DB::table('user_statistics')->insert([
'user_id' => $legacyId,
'uploads' => $uploads,
'downloads' => $downloads,
'pageviews' => $pageviews,
'awards' => $awards,
'created_at' => $now,
'updated_at' => $now,
]);
});
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
protected function sanitizeStatValue($value): int
{
$n = is_numeric($value) ? (int) $value : 0;
if ($n < 0) {
return 0;
}
return $n;
}
protected function sanitizeUsername(string $username): string
{
$username = strtolower(trim($username));
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
}
protected function uniqueUsername(string $base): string
{
$name = $base;
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$this->usedUsernames[$name] = $name;
return $name;
}
protected function prepareEmail(?string $legacyEmail, string $username): string
{
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
$baseLocal = $this->sanitizeEmailLocal($username);
$domain = 'users.skinbase.org';
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
$email = $this->uniqueEmail($email, $baseLocal, $domain);
return $email;
}
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
{
$i = 1;
$local = explode('@', $email)[0];
$current = $email;
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
$current = $local . $i . '@' . $domain;
$i++;
}
$this->usedEmails[$current] = $current;
return $current;
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MigrateFeaturedWorks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$this->info('Starting migration from `featured_works` to `artwork_features`');
if ($dryRun) {
$this->info('Running in dry-run mode; no inserts will be performed.');
}
$inserted = 0;
$skipped = 0;
$total = 0;
$startId = (int) $this->option('start-id');
$chunk = (int) $this->option('chunk');
$mapping = [
3 => 10, // Gold -> high priority
2 => 20, // Silver
1 => 30, // Bronze
4 => 50, // Featured
0 => 100 // default
];
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
if ($startId > 0) {
$this->info("Resuming from featured_id >= {$startId}");
$query = $query->where('featured_id', '>=', $startId);
}
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
foreach ($rows as $row) {
$total++;
if ($limit > 0 && $inserted >= $limit) {
return false; // stop chunking
}
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
if ($artworkId <= 0) {
$skipped++;
continue;
}
// Verify artwork exists
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
if (! $exists) {
$skipped++;
continue;
}
// Avoid duplicate active feature for same artwork
$already = DB::table('artwork_features')
->where('artwork_id', $artworkId)
->where('is_active', true)
->exists();
if ($already) {
$skipped++;
continue;
}
// Determine featured_at
$postDate = $row->post_date ?? null;
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
$featuredAt = Carbon::now();
} else {
try {
$featuredAt = Carbon::parse($postDate);
} catch (\Throwable $e) {
$featuredAt = Carbon::now();
}
}
// Map priority from legacy 'type'
$type = isset($row->type) ? (int) $row->type : 0;
$priority = $mapping[$type] ?? 100;
// Validate created_by: only set if a valid user id exists in new users table
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
$createdBy = null;
}
$record = [
'artwork_id' => $artworkId,
'featured_at' => $featuredAt->toDateTimeString(),
'expires_at' => null,
'priority' => $priority,
'label' => null,
'note' => $row->description ?? null,
'is_active' => 1,
'created_by' => $createdBy,
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
if ($dryRun) {
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
$inserted++;
continue;
}
try {
DB::table('artwork_features')->insert($record);
$inserted++;
} catch (\Throwable $e) {
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
$skipped++;
}
}
// Return true to continue, false to stop chunking
return ($limit > 0 && $inserted >= $limit) ? false : true;
}, 'featured_id');
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
return 0;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetAllUserPasswords extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
public function handle(): int
{
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
$this->info('Aborted. No changes were made.');
return 0;
}
$chunk = (int) $this->option('chunk');
$count = DB::table('users')->count();
if ($count === 0) {
$this->info('No users found.');
return 0;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
foreach ($rows as $row) {
DB::table('users')->where('id', $row->id)->update([
'password' => Hash::make(Str::random(64)),
'needs_password_reset' => true,
'updated_at' => now(),
]);
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
$this->info('All user passwords reset and accounts flagged for password reset.');
return 0;
}
}

41
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array<int, class-string>
*/
protected $commands = [
ImportLegacyUsers::class,
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\ResetAllUserPasswords::class,
];
/**
* Define the application's command schedule.
*/
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}

78
app/Helpers/Thumb.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Storage;
use App\Services\ThumbnailService;
class Thumb
{
/**
* Return a thumbnail URL.
*
* Usage:
* - `Thumb::url($filePath)` - fallback mapping by filename or Storage::url
* - `Thumb::url($filePath, $id, $ext)` - resolve hash-based CDN URL when possible
*
* @param string|null $filePath
* @param int|null $id
* @param string|null $ext
* @param int $size // legacy size code: 4 -> small(320), 6 -> medium(600)
* @return string
*/
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, int $size = 6): string
{
return ThumbnailService::url($filePath, $id, $ext, $size);
}
/**
* Build a new-style thumbnail URL using hash and extension.
* Example: http://files.skinbase.org/md/43/f8/43f87a...360.webp
*
* @param string|null $hash
* @param string|null $ext
* @param string $sizeKey One of sm, md, lg, xl
* @return string|null
*/
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{
return ThumbnailService::fromHash($hash, $ext, $sizeKey);
}
/**
* Build a simple srcset for responsive thumbnails.
* Uses sm (320w) and md (600w) by default to match legacy sizes.
*
* @param string|null $hash
* @param string|null $ext
* @return string|null
*/
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{
return ThumbnailService::srcsetFromHash($hash, $ext);
}
/**
* Return encoded id string using legacy algorithm or fallback base62.
*/
public static function encodeId(int $id): string
{
if (class_exists('\App\Services\LegacyService') && method_exists('\App\Services\LegacyService', 'encode')) {
return \App\Services\LegacyService::encode($id);
}
return self::base62encode($id);
}
private static function base62encode(int $val, int $base = 62, string $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'): string
{
$str = '';
if ($val < 0) return $str;
do {
$i = $val % $base;
$str = $chars[$i] . $str;
$val = intdiv($val - $i, $base);
} while ($val > 0);
return $str;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Http\Request;
class ArtworkController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* GET /api/v1/artworks/{slug}
* Returns a single public artwork resource by slug.
*/
public function show(string $slug)
{
$artwork = $this->service->getPublicArtworkBySlug($slug);
// Return the artwork instance (service already loads lightweight relations).
// Log resolved resource for debugging failing test assertions.
// Return the resolved payload directly to avoid JsonResource wrapping inconsistencies
return response()->json((new ArtworkResource($artwork))->resolve(), 200);
}
/**
* GET /api/v1/categories/{slug}/artworks
* Uses route-model binding for Category (slug). Returns paginated list resource.
*/
public function categoryArtworks(Request $request, Category $category)
{
$perPage = (int) $request->get('per_page', 24);
$paginator = $this->service->getCategoryArtworks($category, $perPage);
return ArtworkListResource::collection($paginator);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class BrowseController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* GET /api/v1/browse
* Public browse feed powered by authoritative artworks table.
*/
public function index(Request $request)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
$paginator = $this->service->browsePublicArtworks($perPage);
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}
* Browse by content type slug.
*/
public function byContentType(Request $request, string $contentTypeSlug)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
try {
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}/{category_path}
* Browse by content type + category path (slug segments).
*/
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
$slugs = array_merge([
strtolower($contentTypeSlug),
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
try {
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ArtworkController extends Controller
{
/**
* Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
*/
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
{
$perPage = (int) ($request->get('per_page', 24));
$query = Artwork::public()->published();
if ($category) {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
});
}
if ($request->filled('q')) {
$q = $request->get('q');
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', '%' . $q . '%')
->orWhere('description', 'like', '%' . $q . '%');
});
}
$sort = $request->get('sort', 'latest');
if ($sort === 'oldest') {
$query->orderBy('published_at', 'asc');
} else {
$query->orderByDesc('published_at');
}
// Important: do NOT eager-load artwork_stats in listings
$artworks = $query->cursorPaginate($perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show a single artwork by slug. Ensure it's public, approved and not deleted.
*/
public function show(Artwork $artwork): View
{
if (! $artwork->is_public || ! $artwork->is_approved || $artwork->trashed()) {
abort(404);
}
return view('artworks.show', ['artwork' => $artwork]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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);
}
$categoriesByType[$ct->slug] = $rootCats;
}
return view('browse-categories', [
'contentTypes' => $contentTypes,
'categoriesByType' => $categoriesByType,
'categories' => $categories,
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
class CategoryPageController extends Controller
{
public function show(Request $request, string $contentTypeSlug, string $categoryPath = null)
{
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
if (! $contentType) {
abort(404);
}
if ($categoryPath === null || $categoryPath === '') {
// No category path: show content-type landing page (e.g., /wallpapers)
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $contentType->name;
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
return view('legacy.content-type', compact(
'contentType',
'rootCategories',
'page_title',
'page_meta_description'
));
}
$segments = array_filter(explode('/', $categoryPath));
if (empty($segments)) {
return redirect('/browse-categories');
}
// Traverse categories by slug path within the content type
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', strtolower(array_shift($segments)))
->first();
if (! $current) {
abort(404);
}
foreach ($segments as $slug) {
$current = $current->children()->where('slug', strtolower($slug))->first();
if (! $current) {
abort(404);
}
}
$category = $current;
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
// Placeholder artworks paginator (until artwork data is wired).
$page = max(1, (int) $request->query('page', 1));
$artworks = new LengthAwarePaginator([], 0, 40, $page, [
'path' => $request->url(),
'query' => $request->query(),
]);
$page_title = $category->name;
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
return view('legacy.category-slug', compact(
'contentType',
'category',
'subcategories',
'rootCategories',
'artworks',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ArtController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function show(Request $request, $id, $slug = null)
{
// handle comment POST from legacy form
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
if (auth()->check()) {
try {
\Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_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('legacy.placeholder', ['title' => 'Artwork Not Found']);
}
// load comments for artwork (legacy schema)
try {
$comments = \Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_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('legacy.art', $data);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarController extends Controller
{
public function show(Request $request, $id, $name = null)
{
$user_id = (int) $id;
// default avatar in project public gfx
$defaultAvatar = public_path('gfx/avatar.jpg');
try {
$icon = DB::connection('legacy')->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);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class BrowseController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.";
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
$perPage = (int) $request->get('per_page', 24);
$categoryPath = trim((string) $request->query('category', ''), '/');
try {
if ($categoryPath !== '') {
$slugs = array_values(array_filter(explode('/', $categoryPath)));
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->getArtworksByCategoryPath($slugs, $perPage);
} else {
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->browsePublicArtworks($perPage);
}
} catch (ModelNotFoundException $e) {
abort(404);
}
if (count($artworks) === 0) {
Log::warning('browse.missing_artworks', [
'url' => $request->fullUrl(),
'category_path' => $categoryPath ?: null,
]);
abort(410);
}
// Shape data for the legacy Blade while using authoritative tables only.
$artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork));
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
}
private function mapArtwork(Artwork $artwork): object
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryPath = $primaryCategory?->full_slug_path;
$contentTypeSlug = $primaryCategory?->contentType?->slug;
$webUrl = $contentTypeSlug && $categoryPath
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $artwork->slug
: null;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
// Include ordering parameter used by cursor paginator so links can be generated
'published_at' => $artwork->published_at?->toAtomString(),
'slug' => $artwork->slug,
'name' => $artwork->title,
'category_name' => $primaryCategory->name ?? '',
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'url' => $webUrl ?? '#',
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Support\Str;
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)
{
// Parse request path after '/category' to support unlimited depth and legacy routes
$path = trim($request->path(), '/');
$segments = array_values(array_filter(explode('/', $path)));
// Expecting segments like ['category', '{contentType}', '{...categorySlugs}']
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
return view('legacy.placeholder');
}
$parts = array_slice($segments, 1);
// If first part is numeric, attempt id->category resolution and redirect to canonical slug URL
$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);
}
}
// Build slug list: first element is content type slug, rest are category slugs
$contentTypeSlug = array_shift($parts);
$slugs = array_merge([$contentTypeSlug], $parts);
$perPage = (int) $request->get('per_page', 40);
try {
$artworks = $this->artworkService->getArtworksByCategoryPath($slugs, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
// Resolve Category model for page meta and subcategories
// Use the contentType + path traversal to find the category
try {
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
// If deeper path exists, traverse
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) {
// Category resolution failed
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('legacy.category', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'group',
'category',
'subcategories',
'artworks'
));
}
public function browseCategories()
{
$data = $this->legacy->browseCategories();
return view('legacy.categories', $data);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Legacy;
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';
// Handle post (store 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);
}
}
// Capture Banner output
ob_start();
\App\Banner::ShowResponsiveAd();
$adHtml = ob_get_clean();
// Capture Chat HTML
ob_start();
$userID = $_SESSION['web_login']['user_id'] ?? null;
$chat->ShowChat(50, $userID);
$chatHtml = ob_get_clean();
// Load smileys from legacy DB
try {
$smileys = DB::connection('legacy')->table('smileys')->select('code', 'picture', 'emotion')->get();
} catch (\Throwable $e) {
$smileys = collect();
}
return view('legacy.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
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) {
// Return partial gallery for the given date
$arts = $this->fetchByDate($datum);
return view('legacy.partials.daily-uploads-grid', ['arts' => $arts])->render();
}
// Build date tabs (today .. -14 days)
$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),
];
}
// initial content: recent (last 7 days)
$recent = $this->fetchRecent();
return view('legacy.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,63 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
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('legacy.featured-artworks', [
'artworks' => $artworks,
'type' => $type,
'artworkTypes' => $artworkTypes,
'page_title' => $pageTitle,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Legacy;
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('legacy.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('legacy.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('legacy.forum.topic', $data);
}
return view('legacy.forum.posts', $data);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
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';
// Use new ArtworkService as primary data source
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
// If service returned a paginator, extract the first model for the single "featured" slot
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$featured = $featuredResult->getCollection()->first();
} elseif (is_array($featuredResult)) {
$featured = $featuredResult[0] ?? null;
} else {
// Collection or single item
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
}
// Provide a memberFeatured fallback so the legacy view always has a value
$memberFeatured = $featured;
$latestUploads = $this->artworks->getLatestArtworks(20);
// Legacy forum/news data not available in new services yet — provide empty defaults
$forumNews = [];
$ourNews = [];
$latestForumActivity = [];
return view('legacy.home', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'featured',
'memberFeatured',
'latestUploads',
'forumNews',
'ourNews',
'latestForumActivity'
));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Legacy;
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;
// Handle comment POST
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::connection('legacy')->table('interviews_comment')->insert([
'nid' => $interviewId,
'author' => $_SESSION['web_login']['username'] ?? 'Anonymous',
'datum' => DB::raw('CURRENT_TIMESTAMP'),
'tekst' => $tekst,
]);
$ar2 = DB::connection('legacy')->table('users')
->where('uname', $_SESSION['web_login']['username'])
->first();
if (!empty($ar2->user_id)) {
DB::connection('legacy')->table('users_statistics')
->where('user_id', $ar2->user_id)
->increment('newscomment');
}
} catch (\Throwable $e) {
// fail silently
}
}
}
try {
$ar = DB::connection('legacy')->table('interviews')->where('id', $id)->first();
} catch (\Throwable $e) {
$ar = null;
}
if (! $ar) {
return redirect('/interviews');
}
try {
$artworks = DB::connection('legacy')->table('wallz')
->where('uname', $ar->username)
->inRandomOrder()
->limit(2)
->get();
} catch (\Throwable $e) {
$artworks = collect();
}
try {
$comments = DB::connection('legacy')->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();
}
// compute total posts per author across interviews_comment
$authors = $comments->pluck('author')->unique()->values()->all();
$postCounts = [];
if (!empty($authors)) {
try {
$counts = DB::connection('legacy')->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) {
// ignore
}
}
$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\Legacy;
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::connection('legacy')->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

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class LatestCommentsController extends Controller
{
public function index(Request $request)
{
$hits = 20;
// Join artwork_comments -> artwork -> user, but only include artworks that are public, approved and published
$query = ArtworkComment::with(['user', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('created_at');
$comments = $query->paginate($hits)->withQueryString();
// Shape results for legacy view
$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->avatar ?? 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('legacy.latest-comments', compact('page_title', 'comments'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Storage;
class LatestController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$perPage = 21;
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->browsePublicArtworks($perPage);
// Shape data for legacy view without legacy tables.
$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('legacy.latest-artworks', [
'artworks' => $artworks,
'page_title' => 'Latest Artworks',
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Legacy;
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('/');
}
// categoryPage returns an array with keys used by legacy.browse
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
$artworks = $result['artworks'] ?? collect();
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view
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('legacy.browse', compact('page_title', 'artworks'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Legacy;
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::connection('legacy')->table('artworks_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('legacy.monthly-commentators', compact('page_title', 'rows'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Legacy;
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::connection('legacy')->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::connection('legacy')->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('legacy.news', compact('news', 'comments', 'page_title'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Services\ArtworkService;
use App\Models\User;
use App\Models\Artwork;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
protected ArtworkService $artworkService;
public function __construct(ArtworkService $artworkService)
{
$this->artworkService = $artworkService;
}
public function show(Request $request, ?int $id = null, ?string $slug = null)
{
// Support /profile (current user) and /profile/{id}/{slug}
$id = $id ?? (Auth::check() ? Auth::id() : null);
if (! $id) {
abort(404);
}
$user = User::find($id);
if (! $user) {
abort(404);
}
// Determine visibility: owner sees all, others only public+approved+published
$isOwner = Auth::check() && Auth::id() === $user->id;
$perPage = 24;
// Use ArtworkService to fetch artworks for the profile
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage);
// Shape data for legacy view expectations
$artworks->getCollection()->transform(function (Artwork $art) {
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
return (object) [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $art->published_at,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
];
});
// Map new User model to legacy view shape expected by templates
$legacyUser = (object) [
'user_id' => $user->id,
'uname' => $user->name,
'real_name' => $user->name,
'icon' => $user->avatar ?? null,
'about_me' => $user->bio ?? null,
];
return view('legacy.profile', [
'user' => $legacyUser,
'artworks' => $artworks,
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Legacy;
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;
// Filter downloads created today and join to artworks that are public, approved and published
$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();
// Map to the legacy browse shape
$paginator->getCollection()->transform(function ($row) {
// $row is a stdClass with artwork_id and num_downloads
$art = $row->artwork ?? null;
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
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; // legacy encoding unavailable; leave 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('legacy.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Legacy;
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::connection('legacy')->table('featured_works as t0')
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
->join('artworks_categories as t2', 't1.category', '=', 't2.category_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', 't2.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);
// Prefer new CDN when artwork exists with hash
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('legacy.today-in-history', [
'artworks' => $artworks,
'page_title' => 'Popular on this day in history',
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Legacy;
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';
}
// Aggregate artwork_stats grouped by artwork.user_id, filtering only public+approved+published artworks
$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');
// Join with users to fetch profile info
$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();
// Map to legacy view shape
$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('legacy.top-authors', compact('page_title', 'authors', 'metric'));
}
}
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TopAuthorsController extends Controller
{
public function index(Request $request)
{
// Top users (most active)
try {
$topUsers = DB::connection('legacy')->table('wallz as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select('t2.user_id', 't2.uname', 't2.icon', DB::raw('SUM(t1.dls) AS total_downloads'), DB::raw('COUNT(*) AS uploads'))
->groupBy('t1.user_id')
->orderByDesc('total_downloads')
->limit(23)
->get();
} catch (\Throwable $e) {
$topUsers = collect();
}
// Top followers
try {
$topFollowers = DB::connection('legacy')->table('friends_list as t1')
->rightJoin('users as t2', 't1.friend_id', '=', 't2.user_id')
->where('t1.friend_id', '>', 0)
->select('t2.uname', 't2.user_id', DB::raw('COUNT(*) as num'))
->groupBy('t1.friend_id')
->orderByDesc('num')
->limit(10)
->get();
} catch (\Throwable $e) {
$topFollowers = collect();
}
// Top commentators
try {
$topCommentators = DB::connection('legacy')->table('artworks_comments as t1')
->join('users as t2', 't1.user_id', '=', 't2.user_id')
->where('t1.user_id', '>', 0)
->select('t2.user_id','t2.uname','t2.user_type','t2.country', DB::raw('COUNT(*) as num_comments'))
->groupBy('t1.user_id')
->orderByDesc('num_comments')
->limit(10)
->get();
// enrich with country info if available
$topCommentators->transform(function ($c) {
if (!empty($c->country)) {
$cn = DB::connection('legacy')->table('country')->select('name','flag')->where('id', $c->country)->first();
$c->country_name = $cn->name ?? null;
$c->country_flag = $cn->flag ?? null;
} else {
$c->country_name = null;
$c->country_flag = null;
}
return $c;
});
} catch (\Throwable $e) {
$topCommentators = collect();
}
return view('legacy.top-authors', compact('topUsers', 'topFollowers', 'topCommentators'));
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Legacy;
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::connection('legacy')->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();
}
// Map artworks to include expected properties for legacy card view
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('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,564 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class LegacyController extends Controller
{
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';
[$featured, $memberFeatured] = $this->featured();
$latestUploads = $this->latestUploads();
$forumNews = $this->forumNews();
$ourNews = $this->ourNews();
$latestForumActivity = $this->latestForumActivity();
return view('legacy.home', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'featured',
'memberFeatured',
'latestUploads',
'forumNews',
'ourNews',
'latestForumActivity'
));
}
public function browse(Request $request)
{
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiastse.";
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
$perPage = 50;
try {
$artworks = 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) {
$placeholder = collect([
(object) [
'id' => 0,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'category' => null,
'datum' => now(),
'category_name' => 'Photography',
'uname' => 'Skinbase',
],
]);
$artworks = new LengthAwarePaginator(
$placeholder,
$placeholder->count(),
$perPage,
1,
['path' => $request->url(), 'query' => $request->query()]
);
}
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
}
public function category(Request $request, string $group, ?string $slug = null, ?int $id = null)
{
$group = 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 redirect('/');
}
$page_title = $group;
$page_meta_description = $group . ' artworks on Skinbase';
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, photography, skins';
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 redirect('/');
}
$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();
} catch (\Throwable $e) {
$artworks = new LengthAwarePaginator([], 0, $perPage, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
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();
}
}
return view('legacy.category', compact(
'group',
'category',
'artworks',
'subcategories',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function browseCategories()
{
$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';
// Load top-level categories (section_id = 0 AND rootid = 0) like the legacy page
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();
// Fallback to legacy table name if empty
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();
}
}
// Fetch all subcategories in one query to avoid N+1 and group them by parent (section_id)
$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()) {
// fallback to skupine table naming
$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 view('legacy.categories', compact(
'categories',
'subgroups',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumIndex()
{
$page_title = 'Forum';
$page_meta_description = 'Skinbase forum threads.';
$page_meta_keywords = 'forum, discussions, topics, skinbase';
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 view('legacy.forum.index', compact(
'topics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumTopic(Request $request, int $topic_id)
{
try {
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (!$topic) {
return redirect('/forum');
}
$page_title = $topic->topic;
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
$page_meta_keywords = 'forum, topic, skinbase';
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
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 = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
if ($subtopics->total() > 0) {
return view('legacy.forum.topic', compact(
'topic',
'subtopics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
// First try topic_id; if empty, retry using legacy tid column
$posts = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
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) {
// will retry with tid
}
if ($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) {
// keep empty paginator
}
}
return view('legacy.forum.posts', compact(
'topic',
'posts',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
/**
* Fetch featured artworks with graceful fallbacks.
*/
private 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; render placeholders
}
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];
}
private 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 [];
}
}
private 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 [];
}
}
private 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 [];
}
}
/**
* Load latest uploads either from cached JSON or DB.
*/
private function latestUploads(): array
{
$uploads = [];
// Try cache file first
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
if (File::exists($cachePath)) {
$json = File::get($cachePath);
$uploads = json_decode($json, true) ?: [];
}
// Fallback to DB if cache missing
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) {
// Soft fail
$uploads = [];
}
}
// Final fallback placeholders
if (empty($uploads)) {
$uploads = [
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],
];
}
return $uploads;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageController extends Controller
{
public function index(Request $request)
{
$userId = $request->user()->id;
$perPage = 50;
// Use legacy connection query builder and join category name to avoid Eloquent model issues
$query = DB::connection('legacy')->table('artworks as a')
->leftJoin('artworks_categories as c', 'a.category', '=', 'c.category_id')
->where('a.user_id', $userId)
->select('a.*', 'c.category_name')
->orderByDesc('a.datum')
->orderByDesc('a.id');
$artworks = $query->paginate($perPage);
return view('manage.index', [
'artworks' => $artworks,
'page_title' => 'Artwork Manager',
]);
}
public function edit(Request $request, $id)
{
$userId = $request->user()->id;
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
$categories = DB::connection('legacy')->table('artworks_categories')->where('section_id', 0)->orderBy('category_id')->get();
return view('manage.edit', [
'artwork' => $artwork,
'categories' => $categories,
'page_title' => 'Edit Artwork: ' . ($artwork->name ?? ''),
]);
}
public function update(Request $request, $id)
{
$userId = $request->user()->id;
$existing = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $existing) {
abort(404);
}
$data = $request->validate([
'name' => 'required|string|max:255',
'section' => 'nullable|integer',
'description' => 'nullable|string',
'artwork' => 'nullable|file|image',
'attachment' => 'nullable|file',
]);
$update = [
'name' => $data['name'],
'category' => $data['section'] ?? $existing->category,
'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');
$filename = basename($path);
$update['picture'] = $filename;
}
// handle attachment upload (zip, etc.)
if ($request->hasFile('attachment')) {
$att = $request->file('attachment');
$attPath = $att->store('public/uploads/attachments');
$update['fname'] = basename($attPath);
}
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update);
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
}
public function destroy(Request $request, $id)
{
$userId = $request->user()->id;
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
// delete files if present (stored in new storage location)
if (!empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/' . $artwork->fname);
}
if (!empty($artwork->picture)) {
Storage::delete('public/uploads/artworks/' . $artwork->picture);
}
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete();
return redirect()->route('manage')->with('status', 'Artwork deleted.');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
// Soft-delete the user (preserve record) — align with soft-delete policy.
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ArtworkController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* Browse artworks for a category (Blade view).
*/
public function category(Request $request, Category $category): View
{
$perPage = (int) $request->get('per_page', 24);
$artworks = $this->service->getCategoryArtworks($category, $perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show single artwork page by slug (Blade view).
*/
public function show(string $slug): View
{
try {
$artwork = $this->service->getPublicArtworkBySlug($slug);
} catch (ModelNotFoundException $e) {
abort(404);
}
// Prepare simple SEO meta data for Blade; keep controller thin.
$meta = [
'title' => $artwork->title,
'description' => str(config($artwork->description ?? ''))->limit(160),
'canonical' => $artwork->canonical_url ?? null,
];
return view('artworks.show', [
'artwork' => $artwork,
'meta' => $meta,
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ArtworkIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'per_page' => 'nullable|integer|min:1|max:100',
'sort' => 'nullable|in:latest,oldest',
'q' => 'nullable|string|max:255',
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue;
class ArtworkListResource extends JsonResource
{
/**
* Transform the resource into an array for listings (browse feed).
*/
public function toArray($request): array
{
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
return [];
}
// Safe accessor to avoid magic __get which may trigger MissingValue errors
$get = function ($key) {
$r = $this->resource;
if ($r instanceof MissingValue || $r === null) {
return null;
}
if (method_exists($r, 'getAttribute')) {
return $r->getAttribute($key);
}
if (is_array($r)) {
return $r[$key] ?? null;
}
if (is_object($r)) {
return $r->{$key} ?? null;
}
return null;
};
$primaryCategory = $this->whenLoaded('categories', function () {
return $this->categories->sortBy('sort_order')->first();
});
// Normalize MissingValue into null so later checks are straightforward
if ($primaryCategory instanceof MissingValue) {
$primaryCategory = null;
}
$contentTypeSlug = null;
$categoryPath = null;
if ($primaryCategory) {
$contentTypeSlug = optional($primaryCategory->contentType)->slug ?? null;
$categoryPath = $primaryCategory->full_slug_path ?? null;
}
$slugVal = $get('slug');
$webUrl = $contentTypeSlug && $categoryPath && $slugVal
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
: null;
return [
'slug' => $slugVal,
'title' => $get('title'),
'description' => $this->when($request->boolean('include_description'), fn() => $get('description')),
'dimensions' => [
'width' => $get('width'),
'height' => $get('height'),
],
'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
'author' => $this->whenLoaded('user', function () {
return [
'name' => $this->user->name ?? null,
];
}),
'category' => $primaryCategory ? [
'slug' => $primaryCategory->slug ?? null,
'name' => $primaryCategory->name ?? null,
'content_type' => $contentTypeSlug,
'url' => $webUrl,
] : null,
'urls' => [
'web' => $webUrl,
'canonical' => $webUrl,
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue;
class ArtworkResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
return [];
}
$get = function ($key) {
$r = $this->resource;
if ($r instanceof MissingValue || $r === null) {
return null;
}
// Eloquent model: prefer getAttribute to avoid magic proxies
if (method_exists($r, 'getAttribute')) {
return $r->getAttribute($key);
}
if (is_array($r)) {
return $r[$key] ?? null;
}
if (is_object($r)) {
return $r->{$key} ?? null;
}
return null;
};
return [
'slug' => $get('slug'),
'title' => $get('title'),
'description' => $get('description'),
'width' => $get('width'),
'height' => $get('height'),
// File URLs: produce public URLs without exposing internal file_path
'file' => [
'name' => $get('file_name') ?? null,
'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
'size' => $get('file_size') ?? null,
'mime_type' => $get('mime_type') ?? null,
],
'categories' => $this->whenLoaded('categories', function () {
return $this->categories->map(fn($c) => [
'slug' => $c->slug ?? null,
'name' => $c->name ?? null,
])->values();
}),
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
'urls' => [
'canonical' => $get('canonical_url') ?? null,
],
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Jobs;
use App\Services\ArtworkStatsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class IncrementArtworkView implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $artworkId;
public int $count;
public string $eventId;
/**
* Require a unique event id to make the job idempotent across retries and concurrency.
*
* @param int $artworkId
* @param string $eventId Unique identifier for this view event (caller must supply)
* @param int $count
*/
public function __construct(int $artworkId, string $eventId, int $count = 1)
{
$this->artworkId = $artworkId;
$this->count = max(1, $count);
$this->eventId = $eventId;
}
/**
* Execute the job.
* Uses Redis setnx to ensure only one worker processes a given eventId.
* Delegates actual DB mutation to ArtworkStatsService which uses transactions.
*/
public function handle(ArtworkStatsService $statsService): void
{
$key = 'artwork:view:processed:' . $this->eventId;
try {
$didSet = false;
try {
$didSet = Redis::setnx($key, 1);
if ($didSet) {
// expire after 1 day to limit key growth
Redis::expire($key, 86400);
}
} catch (\Throwable $e) {
Log::warning('Redis unavailable for IncrementArtworkView; proceeding without dedupe', ['error' => $e->getMessage()]);
// If Redis is not available, fall back to applying delta directly.
// This sacrifices idempotency but ensures metrics are recorded.
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
return;
}
if (! $didSet) {
// Already processed this eventId — idempotent skip
return;
}
// Safe increment using transactional method
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
} catch (\Throwable $e) {
Log::error('IncrementArtworkView job failed', ['artwork_id' => $this->artworkId, 'event_id' => $this->eventId, 'error' => $e->getMessage()]);
// Let the job be retried by throwing
throw $e;
}
}
}

178
app/Models/Artwork.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* App\Models\Artwork
*
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
* @property-read ArtworkStats $stats
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
*/
class Artwork extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'artworks';
protected $fillable = [
'user_id',
'title',
'slug',
'description',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'file_size',
'mime_type',
'width',
'height',
'is_public',
'is_approved',
'published_at',
'hash',
'thumb_ext',
'file_ext'
];
protected $casts = [
'is_public' => 'boolean',
'is_approved' => 'boolean',
'published_at' => 'datetime',
];
/**
* Thumbnail sizes and their options.
* Keys are the size dir used in the CDN URL.
*/
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 the thumbnail URL for this artwork.
* Returns null when no hash or thumb_ext is available.
*/
public function thumbUrl(string $size = 'md'): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) {
return null;
}
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
$h = $this->hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$ext = $this->thumb_ext;
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
}
/**
* Accessor for `$art->thumb` used in legacy views (default medium size).
*/
public function getThumbAttribute(): string
{
return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg';
}
/**
* Accessor for `$art->thumb_url` used in some views.
*/
public function getThumbUrlAttribute(): ?string
{
return $this->thumbUrl('md');
}
/**
* Provide a responsive `srcset` for legacy views.
*/
public function getThumbSrcsetAttribute(): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) return null;
$sm = $this->thumbUrl('sm');
$md = $this->thumbUrl('md');
if (!$sm || !$md) return null;
return $sm . ' 320w, ' . $md . ' 600w';
}
// Relations
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function translations(): HasMany
{
return $this->hasMany(ArtworkTranslation::class);
}
public function stats(): HasOne
{
return $this->hasOne(ArtworkStats::class, 'artwork_id');
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
}
public function comments(): HasMany
{
return $this->hasMany(ArtworkComment::class);
}
public function downloads(): HasMany
{
return $this->hasMany(ArtworkDownload::class);
}
public function features(): HasMany
{
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
}
// Scopes
public function scopePublic(Builder $query): Builder
{
// Compose approved() so behavior is consistent and composable
$table = $this->getTable();
return $query->approved()->where("{$table}.is_public", true);
}
public function scopeApproved(Builder $query): Builder
{
// Respect soft deletes and mark approved content
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true);
}
public function scopePublished(Builder $query): Builder
{
// Respect soft deletes and only include published items up to now
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")
->whereNotNull("{$table}.published_at")
->where("{$table}.published_at", '<=', now());
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

View File

@@ -0,0 +1 @@
<?php

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkComment
*
* @property-read Artwork $artwork
* @property-read User $user
*/
class ArtworkComment extends Model
{
use SoftDeletes;
protected $table = 'artwork_comments';
protected $fillable = [
'artwork_id',
'user_id',
'content',
'is_approved',
];
protected $casts = [
'is_approved' => 'boolean',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkDownload
*
* @property-read Artwork $artwork
* @property-read User|null $user
*/
class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'user_agent',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkFeature extends Model
{
protected $table = 'artwork_features';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'type',
'featured_at',
];
protected $casts = [
'featured_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkStats
*
* @property-read Artwork $artwork
*/
class ArtworkStats extends Model
{
protected $table = 'artwork_stats';
protected $primaryKey = 'artwork_id';
public $incrementing = false;
protected $fillable = [
'artwork_id',
'views',
'downloads',
'favorites',
'rating_avg',
'rating_count',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkTranslation
*
* @property-read Artwork $artwork
*/
class ArtworkTranslation extends Model
{
use SoftDeletes;
protected $table = 'artwork_translations';
protected $fillable = [
'artwork_id',
'locale',
'title',
'description',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

116
app/Models/Category.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'content_type_id','parent_id','name','slug',
'description','image','is_active','sort_order'
];
protected $casts = ['is_active' => 'boolean'];
/**
* Ensure slug is always lowercase and valid before saving.
*/
protected static function boot()
{
parent::boot();
static::saving(function (Category $model) {
if (isset($model->slug)) {
$model->slug = strtolower($model->slug);
if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) {
throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.');
}
}
});
}
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id')
->orderBy('sort_order')->orderBy('name');
}
public function descendants(): HasMany
{
return $this->children()->with('descendants');
}
public function seo(): HasOne
{
return $this->hasOne(CategorySeo::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_category');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
public function getFullSlugPathAttribute(): string
{
return $this->parent
? $this->parent->full_slug_path . '/' . $this->slug
: $this->slug;
}
/**
* Get the full public URL for this category (authoritative spec).
* Example: /photography/abstract/dark
*/
public function getUrlAttribute(): string
{
$contentTypeSlug = strtolower($this->contentType->slug);
$path = strtolower($this->full_slug_path);
return '/' . $contentTypeSlug . ($path ? '/' . $path : '');
}
/**
* Get the canonical URL for SEO (authoritative spec).
* Example: https://skinbase.org/photography/abstract/dark
*/
public function getCanonicalUrlAttribute(): string
{
return 'https://skinbase.org' . $this->url;
}
public function getBreadcrumbsAttribute(): array
{
return $this->parent
? array_merge($this->parent->breadcrumbs, [$this])
: [$this];
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CategorySeo extends Model
{
protected $table = 'category_seo';
protected $primaryKey = 'category_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'category_id','meta_title','meta_description',
'meta_keywords','canonical_url'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CategoryTranslation extends Model
{
use SoftDeletes;
public $timestamps = false;
protected $fillable = [
'category_id','locale','name','description'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ContentType extends Model
{
protected $fillable = ['name','slug','description'];
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
public function rootCategories(): HasMany
{
return $this->categories()->whereNull('parent_id');
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

50
app/Models/User.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Policies;
use App\Models\Artwork;
use App\Models\User;
class ArtworkPolicy
{
/**
* Global before hook: admins can do everything.
* Accepts null $user to allow public checks to continue.
*/
public function before($user, $ability)
{
if (! $user) {
return null;
}
if ($this->isAdmin($user)) {
return true;
}
return null;
}
protected function isAdmin(User $user): bool
{
if (isset($user->is_admin)) {
return (bool) $user->is_admin;
}
if (method_exists($user, 'isAdmin')) {
return (bool) $user->isAdmin();
}
if (method_exists($user, 'hasRole')) {
return (bool) $user->hasRole('admin');
}
return false;
}
/**
* Public view: only approved + public + not-deleted artworks.
*/
public function view(?User $user, Artwork $artwork): bool
{
return $artwork->is_public && $artwork->is_approved && ! $artwork->trashed();
}
/**
* Any authenticated user can create artworks.
*/
public function create(?User $user): bool
{
return (bool) $user;
}
/**
* Owner can update their own artwork.
*/
public function update(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id;
}
/**
* Owner can delete their own artwork (soft delete).
*/
public function delete(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id;
}
/**
* Restore: owner or admin can restore soft-deleted artwork.
*/
public function restore(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id || $this->isAdmin($user);
}
/**
* Force delete reserved for admins only.
*/
public function forceDelete(User $user, Artwork $artwork): bool
{
return $this->isAdmin($user);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use App\Models\Artwork;
use App\Policies\ArtworkPolicy;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
Artwork::class => ArtworkPolicy::class,
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
$this->registerPolicies();
}
}

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

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}