Auth: convert auth views and verification email to Nova layout

This commit is contained in:
2026-02-21 07:37:08 +01:00
parent 93b009d42a
commit 795c7a835f
117 changed files with 5385 additions and 1291 deletions

View File

@@ -2,124 +2,308 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\AvatarService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
use Carbon\Carbon;
class AvatarsMigrate extends Command class AvatarsMigrate extends Command
{ {
protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}'; /**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate
{--dry-run : Do not write files or update database}
{--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan}
';
protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata'; /**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
public function __construct(private readonly AvatarService $service) /**
{ * Allowed MIME types for source images.
parent::__construct(); *
} * @var array
*/
protected $allowed = [
'image/jpeg',
'image/png',
'image/webp',
];
/**
* Target sizes to generate.
*
* @var int[]
*/
protected $sizes = [32, 64, 128, 256, 512];
public function handle(): int public function handle(): int
{ {
$force = (bool) $this->option('force'); $dry = $this->option('dry-run');
$limit = max(0, (int) $this->option('limit')); $force = $this->option('force');
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$this->info('Starting avatar migration...'); $this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : ''));
$rows = DB::table('user_profiles as p') // Detect processing backend: Intervention preferred, GD fallback
->leftJoin('users as u', 'u.id', '=', 'p.user_id') $useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
->select([ if ($useIntervention) {
'p.user_id', Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
'p.avatar_hash',
'p.avatar_legacy',
'u.icon as user_icon',
])
->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
->where(function ($query) {
$query->whereNotNull('p.avatar_legacy')
->orWhereNotNull('u.icon');
})
->orderBy('p.user_id')
->when($limit > 0, fn ($query) => $query->limit($limit))
->get();
if ($rows->isEmpty()) {
$this->info('No avatars require migration.');
return self::SUCCESS;
} }
$migrated = 0; $bar = null;
$skipped = 0;
$failed = 0;
foreach ($rows as $row) { User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
$userId = (int) $row->user_id; foreach ($users as $user) {
$legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon); /** @var UserProfile|null $profile */
$profile = $user->profile;
if ($legacyName === null) { if (!$profile) {
$skipped++;
continue;
}
$path = $this->locateLegacyAvatarPath($userId, $legacyName);
if ($path === null) {
$failed++;
$this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
continue;
}
try {
$hash = $this->service->storeFromLegacyFile($userId, $path);
if (!$hash) {
$failed++;
$this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
continue; continue;
} }
$migrated++; // Skip if already migrated unless --force
$this->line("User {$userId}: migrated ({$hash})"); if (!$force && !empty($profile->avatar_hash)) {
} catch (\Throwable $e) { $this->line("[skip] user={$user->id} already migrated");
$failed++; continue;
$this->warn("User {$userId}: migration failed ({$e->getMessage()})"); }
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
if (!$source) {
$this->line("[noop] user={$user->id} no legacy file found");
continue;
}
try {
$this->line("[proc] user={$user->id} file={$source}");
if ($useIntervention) {
$img = Image::make($source);
$mime = $img->mime();
} else {
$info = @getimagesize($source);
$mime = $info['mime'] ?? null;
}
if (!in_array($mime, $this->allowed, true)) {
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
continue;
}
// Re-encode full original to webp (strip metadata)
if ($useIntervention) {
$originalBlob = (string) $img->encode('webp', 82);
} else {
$originalBlob = $this->gdEncodeWebp($source, 82);
}
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
$idPart = sha1(sprintf('%08d', $user->id));
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
if ($dry) {
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
} else {
// Use hash-based directory structure: avatars/ab/cd/{hash}/
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
Storage::disk('public')->makeDirectory($dir);
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
// Generate sizes
foreach ($this->sizes as $size) {
if ($useIntervention) {
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
$constraint->upsize();
});
$thumbBlob = (string) $thumb->encode('webp', 82);
} else {
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
}
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
}
// Update DB
$profile->avatar_hash = $hash;
$profile->avatar_mime = 'image/webp';
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$this->line("[ok] user={$user->id} migrated hash={$hash}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
if (file_exists($legacyFile)) {
@unlink($legacyFile);
$this->line("[rm] removed legacy file {$legacyFile}");
}
}
}
} catch (\Exception $e) {
$this->error("[error] user={$user->id} {$e->getMessage()}");
continue;
}
}
});
$this->info('Avatar migration complete');
return 0;
}
/**
* Try to find a legacy avatar file for a user/profile.
*
* @param UserProfile $profile
* @param int $userId
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
{
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
if (file_exists($p)) {
return $p;
} }
} }
$this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}"); // 2) Try files named by user id with common extensions
$exts = ['png','jpg','jpeg','webp','gif'];
return $failed > 0 ? self::FAILURE : self::SUCCESS; foreach ($exts as $ext) {
} $p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
if (file_exists($p)) {
private function normalizeLegacyName(?string $value): ?string return $p;
{ }
if (!$value) {
return null;
} }
$trimmed = trim($value); // 3) Try any file under legacy dir that contains the user id in name
if ($trimmed === '') { if (is_dir($legacyBase)) {
return null; $files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
} if (!empty($files)) {
return $files[0];
return basename(urldecode($trimmed));
}
private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
{
$candidates = [
public_path('avatar/' . $legacyName),
public_path('avatar/' . $userId . '/' . $legacyName),
public_path('user-picture/' . $legacyName),
storage_path('app/public/avatar/' . $legacyName),
storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
storage_path('app/public/user-picture/' . $legacyName),
base_path('oldSite/www/files/usericons/' . $legacyName),
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
return $candidate;
} }
} }
return null; return null;
} }
/**
* GD-based encode to WebP binary blob.
*
* @param string $path
* @param int $quality
* @return string
*/
protected function gdEncodeWebp(string $path, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
ob_start();
imagewebp($src, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
return $data;
}
/**
* Create a center-cropped square thumbnail and return WebP binary.
*
* @param string $path
* @param int $size
* @param int $quality
* @return string
*/
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
$w = imagesx($src);
$h = imagesy($src);
$min = min($w, $h);
$srcX = (int) floor(($w - $min) / 2);
$srcY = (int) floor(($h - $min) / 2);
$dst = imagecreatetruecolor($size, $size);
// preserve transparency
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
ob_start();
imagewebp($dst, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return $data;
}
/**
* Create GD image resource from file path.
*
* @param string $path
* @return resource|false
*/
protected function gdCreateResource(string $path)
{
$info = @getimagesize($path);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
switch ($mime) {
case 'image/jpeg':
return imagecreatefromjpeg($path);
case 'image/png':
return imagecreatefrompng($path);
case 'image/webp':
if (function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
default:
return false;
}
}
} }

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EnforceUsernamePolicy extends Command
{
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$logPath = storage_path('logs/username_migration.log');
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
$updated = 0;
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
foreach ($users as $user) {
$current = strtolower(trim((string) ($user->username ?? '')));
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
$base = 'user'.$user->id;
}
$candidate = substr($base, 0, UsernamePolicy::max());
$suffix = 1;
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
$needsUpdate = $candidate !== $current;
if (! $needsUpdate) {
$used[$candidate] = (int) $user->id;
continue;
}
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
if (! $dryRun) {
DB::transaction(function () use ($user, $current, $candidate): void {
if ($current !== '' && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $current,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($current !== '' && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $current],
[
'new_username' => $candidate,
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
DB::table('users')->where('id', (int) $user->id)->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
});
}
$used[$candidate] = (int) $user->id;
$updated++;
}
});
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
return self::SUCCESS;
}
}

View File

@@ -4,8 +4,10 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\ForumCategory; use App\Models\ForumCategory;
use App\Models\User;
use App\Models\ForumThread; use App\Models\ForumThread;
use App\Models\ForumPost; use App\Models\ForumPost;
use Exception; use Exception;
@@ -13,12 +15,19 @@ use App\Services\BbcodeConverter;
class ForumMigrateOld extends Command class ForumMigrateOld extends Command
{ {
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}'; protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables'; protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
protected string $logPath; protected string $logPath;
protected ?int $limit = null;
protected ?int $deletedUserId = null;
/** @var array<int,int> */
protected array $missingUserIds = [];
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
@@ -33,6 +42,17 @@ class ForumMigrateOld extends Command
$dry = $this->option('dry-run'); $dry = $this->option('dry-run');
$only = $this->option('only'); $only = $this->option('only');
$chunk = (int)$this->option('chunk'); $chunk = (int)$this->option('chunk');
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
$only = $only === 'attachments' ? 'gallery' : $only;
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
return 1;
}
if ($chunk < 1) {
$chunk = 500;
}
try { try {
if (!$only || $only === 'categories') { if (!$only || $only === 'categories') {
@@ -51,6 +71,10 @@ class ForumMigrateOld extends Command
$this->migrateGallery($dry, $chunk); $this->migrateGallery($dry, $chunk);
} }
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
$this->repairOrphanPosts($dry);
}
if ($this->option('report')) { if ($this->option('report')) {
$this->generateReport(); $this->generateReport();
} }
@@ -74,8 +98,13 @@ class ForumMigrateOld extends Command
->select('root_id') ->select('root_id')
->distinct() ->distinct()
->where('root_id', '>', 0) ->where('root_id', '>', 0)
->orderBy('root_id')
->pluck('root_id'); ->pluck('root_id');
if ($this->limit !== null && $this->limit > 0) {
$roots = $roots->take($this->limit);
}
$this->info('Found ' . $roots->count() . ' legacy root ids'); $this->info('Found ' . $roots->count() . ' legacy root ids');
foreach ($roots as $rootId) { foreach ($roots as $rootId) {
@@ -90,10 +119,12 @@ class ForumMigrateOld extends Command
continue; continue;
} }
ForumCategory::updateOrCreate( DB::transaction(function () use ($rootId, $name, $slug) {
['id' => $rootId], ForumCategory::updateOrCreate(
['name' => $name, 'slug' => $slug] ['id' => $rootId],
); ['name' => $name, 'slug' => $slug]
);
}, 3);
} }
$this->info('Categories migrated'); $this->info('Categories migrated');
@@ -107,15 +138,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics')->orderBy('topic_id'); $query = $legacy->table('forum_topics')->orderBy('topic_id');
$total = $query->count(); $total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total threads to process: {$total}"); $this->info("Total threads to process: {$total}");
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->start(); $bar->start();
$processed = 0;
$limit = $this->limit;
// chunk by legacy primary key `topic_id` // chunk by legacy primary key `topic_id`
$query->chunkById($chunk, function ($rows) use ($dry, $bar) { $query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) { foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance(); $bar->advance();
$processed++;
$data = [ $data = [
'id' => $r->topic_id, 'id' => $r->topic_id,
@@ -137,7 +179,9 @@ class ForumMigrateOld extends Command
continue; continue;
} }
ForumThread::updateOrCreate(['id' => $data['id']], $data); DB::transaction(function () use ($data) {
ForumThread::updateOrCreate(['id' => $data['id']], $data);
}, 3);
} }
}, 'topic_id'); }, 'topic_id');
@@ -153,15 +197,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_posts')->orderBy('post_id'); $query = $legacy->table('forum_posts')->orderBy('post_id');
$total = $query->count(); $total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total posts to process: {$total}"); $this->info("Total posts to process: {$total}");
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->start(); $bar->start();
$processed = 0;
$limit = $this->limit;
// legacy forum_posts uses `post_id` as primary key // legacy forum_posts uses `post_id` as primary key
$query->chunkById($chunk, function ($rows) use ($dry, $bar) { $query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) { foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance(); $bar->advance();
$processed++;
$data = [ $data = [
'id' => $r->post_id, 'id' => $r->post_id,
@@ -177,7 +232,9 @@ class ForumMigrateOld extends Command
continue; continue;
} }
ForumPost::updateOrCreate(['id' => $data['id']], $data); DB::transaction(function () use ($data) {
ForumPost::updateOrCreate(['id' => $data['id']], $data);
}, 3);
} }
}, 'post_id'); }, 'post_id');
@@ -243,15 +300,50 @@ class ForumMigrateOld extends Command
protected function resolveUserId($userId) protected function resolveUserId($userId)
{ {
if (empty($userId)) { if (empty($userId)) {
return 1; return $this->resolveDeletedUserId();
} }
// check users table in default connection // check users table in default connection
if (\DB::table('users')->where('id', $userId)->exists()) { if (DB::table('users')->where('id', $userId)->exists()) {
return $userId; return $userId;
} }
return 1; // fallback system user $uid = (int) $userId;
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
$this->missingUserIds[] = $uid;
}
return $this->resolveDeletedUserId();
}
protected function resolveDeletedUserId(): int
{
if ($this->deletedUserId !== null) {
return $this->deletedUserId;
}
$userOne = User::query()->find(1);
if ($userOne) {
$this->deletedUserId = 1;
return $this->deletedUserId;
}
$fallback = User::query()->orderBy('id')->first();
if ($fallback) {
$this->deletedUserId = (int) $fallback->id;
return $this->deletedUserId;
}
$created = User::query()->create([
'name' => 'Deleted User',
'email' => 'deleted-user+forum@skinbase.local',
'password' => Hash::make(Str::random(64)),
'role' => 'user',
]);
$this->deletedUserId = (int) $created->id;
return $this->deletedUserId;
} }
protected function convertLegacyMessage($msg) protected function convertLegacyMessage($msg)
@@ -260,6 +352,114 @@ class ForumMigrateOld extends Command
return $converter->convert($msg); return $converter->convert($msg);
} }
protected function repairOrphanPosts(bool $dry): void
{
$this->info('Repairing orphan posts');
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
$orphanCount = (clone $orphansQuery)->count();
if ($orphanCount === 0) {
$this->info('No orphan posts found.');
return;
}
$this->warn("Found {$orphanCount} orphan posts.");
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
if ($repairThread === null) {
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
});
return;
}
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
if ($dry) {
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
});
return;
}
$updated = 0;
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
/** @var ForumPost $post */
foreach ($posts as $post) {
$post->thread_id = $repairThread->id;
$post->is_edited = true;
$post->edited_at = $post->edited_at ?: now();
$post->save();
$updated++;
}
}, 3);
}, 'id');
$latestPostAt = ForumPost::query()
->where('thread_id', $repairThread->id)
->max('created_at');
if ($latestPostAt) {
$repairThread->last_post_at = $latestPostAt;
$repairThread->save();
}
$this->info("Repaired orphan posts: {$updated}");
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
}
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
{
$slug = 'migration-orphaned-posts';
$existing = ForumThread::query()->where('slug', $slug)->first();
if ($existing) {
return $existing;
}
$category = ForumCategory::query()->ordered()->first();
if (!$category && !$dry) {
$category = ForumCategory::query()->create([
'name' => 'Migration Repairs',
'slug' => 'migration-repairs',
'parent_id' => null,
'position' => 9999,
]);
}
if (!$category) {
return null;
}
if ($dry) {
return new ForumThread([
'id' => 0,
'slug' => $slug,
'category_id' => $category->id,
]);
}
return ForumThread::query()->create([
'category_id' => $category->id,
'user_id' => $this->resolveDeletedUserId(),
'title' => 'Migration: Orphaned Posts Recovery',
'slug' => $slug,
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'staff',
'last_post_at' => now(),
]);
}
protected function generateReport() protected function generateReport()
{ {
$this->info('Generating migration report'); $this->info('Generating migration report');
@@ -275,12 +475,32 @@ class ForumMigrateOld extends Command
'categories' => ForumCategory::count(), 'categories' => ForumCategory::count(),
'threads' => ForumThread::count(), 'threads' => ForumThread::count(),
'posts' => ForumPost::count(), 'posts' => ForumPost::count(),
'attachments' => \DB::table('forum_attachments')->count(), 'attachments' => DB::table('forum_attachments')->count(),
];
$orphans = ForumPost::query()
->whereDoesntHave('thread')
->count();
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
$report = [
'missing_users_count' => count($this->missingUserIds),
'missing_users' => $this->missingUserIds,
'orphan_posts' => $orphans,
'timestamp_mismatches' => [
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
],
]; ];
$this->info('Legacy counts: ' . json_encode($legacyCounts)); $this->info('Legacy counts: ' . json_encode($legacyCounts));
$this->info('New counts: ' . json_encode($newCounts)); $this->info('New counts: ' . json_encode($newCounts));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts)); $this->info('Report: ' . json_encode($report));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
} }
protected function log(string $msg) protected function log(string $msg)
@@ -301,14 +521,25 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics_gallery')->orderBy('id'); $query = $legacy->table('forum_topics_gallery')->orderBy('id');
$total = $query->count(); $total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total gallery items to process: {$total}"); $this->info("Total gallery items to process: {$total}");
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->start(); $bar->start();
$query->chunkById($chunk, function ($rows) use ($dry, $bar) { $processed = 0;
$limit = $this->limit;
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) { foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance(); $bar->advance();
$processed++;
// expected legacy fields: id, name, category (topic id), folder, datum, description // expected legacy fields: id, name, category (topic id), folder, datum, description
$topicId = $r->category ?? ($r->topic_id ?? null); $topicId = $r->category ?? ($r->topic_id ?? null);
@@ -368,16 +599,21 @@ class ForumMigrateOld extends Command
continue; continue;
} }
\App\Models\ForumAttachment::create([ DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
'post_id' => $postId, \App\Models\ForumAttachment::query()->updateOrCreate(
'file_path' => $relativePath, [
'file_size' => $fileSize ?? 0, 'post_id' => $postId,
'mime_type' => $mimeType, 'file_path' => $relativePath,
'width' => $width, ],
'height' => $height, [
'created_at' => now(), 'file_size' => $fileSize ?? 0,
'updated_at' => now(), 'mime_type' => $mimeType,
]); 'width' => $width,
'height' => $height,
'updated_at' => now(),
]
);
}, 3);
} }
}, 'id'); }, 'id');

View File

@@ -3,9 +3,11 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\User; use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ImportLegacyUsers extends Command class ImportLegacyUsers extends Command
@@ -15,9 +17,13 @@ class ImportLegacyUsers extends Command
protected array $usedUsernames = []; protected array $usedUsernames = [];
protected array $usedEmails = []; protected array $usedEmails = [];
protected string $migrationLogPath;
public function handle(): int public function handle(): int
{ {
$this->migrationLogPath = storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
$this->usedUsernames = User::pluck('username', 'username')->filter()->all(); $this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all(); $this->usedEmails = User::pluck('email', 'email')->filter()->all();
@@ -56,9 +62,19 @@ class ImportLegacyUsers extends Command
protected function importRow($row, $statRow = null): void protected function importRow($row, $statRow = null): void
{ {
$legacyId = (int) $row->user_id; $legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId)); $rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId));
$baseUsername = $this->sanitizeUsername($rawLegacyUsername);
$username = $this->uniqueUsername($baseUsername); $username = $this->uniqueUsername($baseUsername);
$normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername);
if ($normalizedLegacy !== $username) {
@file_put_contents(
$this->migrationLogPath,
sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username),
FILE_APPEND
);
}
$email = $this->prepareEmail($row->email ?? null, $username); $email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null; $legacyPassword = $row->password2 ?: $row->password ?: null;
@@ -88,6 +104,7 @@ class ImportLegacyUsers extends Command
DB::table('users')->insert([ DB::table('users')->insert([
'id' => $legacyId, 'id' => $legacyId,
'username' => $username, 'username' => $username,
'username_changed_at' => now(),
'name' => $row->real_name ?: $username, 'name' => $row->real_name ?: $username,
'email' => $email, 'email' => $email,
'password' => $passwordHash, 'password' => $passwordHash,
@@ -126,6 +143,21 @@ class ImportLegacyUsers extends Command
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
}); });
} }
@@ -143,19 +175,12 @@ class ImportLegacyUsers extends Command
protected function sanitizeUsername(string $username): string protected function sanitizeUsername(string $username): string
{ {
$username = strtolower(trim($username)); return UsernamePolicy::sanitizeLegacy($username);
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
} }
protected function uniqueUsername(string $base): string protected function uniqueUsername(string $base): string
{ {
$name = $base; $name = UsernamePolicy::uniqueCandidate($base);
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$this->usedUsernames[$name] = $name; $this->usedUsernames[$name] = $name;
return $name; return $name;
} }

View File

@@ -22,6 +22,7 @@ class Kernel extends ConsoleKernel
*/ */
protected $commands = [ protected $commands = [
ImportLegacyUsers::class, ImportLegacyUsers::class,
\App\Console\Commands\EnforceUsernamePolicy::class,
ImportCategories::class, ImportCategories::class,
MigrateFeaturedWorks::class, MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class, \App\Console\Commands\AvatarsMigrate::class,

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class UsernameApprovalController extends Controller
{
public function pending(): JsonResponse
{
$rows = DB::table('username_approval_requests')
->where('status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'requested_username',
'context',
'similar_to',
'payload',
'created_at',
]);
return response()->json(['data' => $rows], Response::HTTP_OK);
}
public function approve(int $id, Request $request): JsonResponse
{
$row = DB::table('username_approval_requests')->where('id', $id)->first();
if (! $row) {
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
}
if ((string) $row->status !== 'pending') {
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
DB::beginTransaction();
try {
DB::table('username_approval_requests')
->where('id', $id)
->update([
'status' => 'approved',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'approved',
], Response::HTTP_OK);
}
public function reject(int $id, Request $request): JsonResponse
{
$affected = DB::table('username_approval_requests')
->where('id', $id)
->where('status', 'pending')
->update([
'status' => 'rejected',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ($affected === 0) {
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'rejected',
], Response::HTTP_OK);
}
private function applyProfileRename(int $userId, string $requestedUsername): void
{
$user = User::query()->find($userId);
if (! $user) {
return;
}
$requested = UsernamePolicy::normalize($requestedUsername);
if ($requested === '') {
throw new \RuntimeException('Requested username is invalid.');
}
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$requested])
->where('id', '!=', $userId)
->exists();
if ($exists) {
throw new \RuntimeException('Requested username is already taken.');
}
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($old === $requested) {
return;
}
$user->username = $requested;
$user->username_changed_at = now();
$user->save();
if ($old !== '') {
DB::table('username_history')->insert([
'user_id' => $userId,
'old_username' => $old,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $requested,
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}

View File

@@ -535,6 +535,7 @@ final class UploadController extends Controller
'upload_id' => (string) $upload->id, 'upload_id' => (string) $upload->id,
'status' => (string) $upload->status, 'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(), 'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (UploadOwnershipException $e) { } catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UsernameAvailabilityController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$candidate = UsernamePolicy::normalize((string) $request->query('username', ''));
$validator = validator(
['username' => $candidate],
['username' => UsernameRequest::formatRules()]
);
if ($validator->fails()) {
return response()->json([
'available' => false,
'normalized' => $candidate,
'errors' => $validator->errors()->toArray(),
], 422);
}
$ignoreUserId = $request->user()?->id;
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$candidate])
->when($ignoreUserId !== null, fn ($q) => $q->where('id', '!=', (int) $ignoreUserId))
->exists();
return response()->json([
'available' => ! $exists,
'normalized' => $candidate,
]);
}
}

View File

@@ -3,23 +3,46 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Mail\RegistrationVerificationMail;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Registered; use App\Services\Security\RecaptchaVerifier;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
public function __construct(
private readonly RecaptchaVerifier $recaptchaVerifier
)
{
}
/** /**
* Display the registration view. * Display the registration view.
*/ */
public function create(): View public function create(Request $request): View
{ {
return view('auth.register'); return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
]);
}
public function notice(Request $request): View
{
$email = (string) session('registration_email', '');
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
return view('auth.register-notice', [
'email' => $email,
'resendSeconds' => $remaining,
]);
} }
/** /**
@@ -29,22 +52,127 @@ class RegisteredUserController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'website' => ['nullable', 'max:0'],
]); ]);
if ($this->recaptchaVerifier->isEnabled()) {
$request->validate([
'g-recaptcha-response' => ['required', 'string'],
]);
$verified = $this->recaptchaVerifier->verify(
(string) $request->input('g-recaptcha-response', ''),
$request->ip()
);
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'reCAPTCHA verification failed. Please try again.']);
}
}
$user = User::create([ $user = User::create([
'name' => $request->name, 'username' => null,
'email' => $request->email, 'name' => Str::before((string) $validated['email'], '@'),
'password' => Hash::make($request->password), 'email' => $validated['email'],
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
]); ]);
event(new Registered($user)); $token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Auth::login($user); Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
return redirect(route('dashboard', absolute: false)); $cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown((string) $validated['email'], $cooldown);
return redirect(route('register.notice', absolute: false))
->with('status', 'Verification email sent. Please check your inbox.')
->with('registration_email', (string) $validated['email']);
}
public function resendVerification(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$email = (string) $validated['email'];
$remaining = $this->resendRemainingSeconds($email);
if ($remaining > 0) {
return back()
->with('registration_email', $email)
->withErrors(['email' => "Please wait {$remaining} seconds before resending."]);
}
$user = User::query()
->where('email', $email)
->whereNull('email_verified_at')
->where('onboarding_step', 'email')
->first();
if (! $user) {
return back()
->with('registration_email', $email)
->withErrors(['email' => 'No pending verification found for this email.']);
}
DB::table('user_verification_tokens')->where('user_id', $user->id)->delete();
$token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
$cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown($email, $cooldown);
return redirect(route('register.notice', absolute: false))
->with('registration_email', $email)
->with('status', 'Verification email resent. Please check your inbox.');
}
private function resendCooldownSeconds(): int
{
return max(5, (int) config('antispam.register.resend_cooldown_seconds', 60));
}
private function resendCooldownCacheKey(string $email): string
{
return 'register:resend:cooldown:' . sha1(strtolower(trim($email)));
}
private function setResendCooldown(string $email, int $seconds): void
{
$until = CarbonImmutable::now()->addSeconds($seconds)->timestamp;
Cache::put($this->resendCooldownCacheKey($email), $until, $seconds + 5);
}
private function resendRemainingSeconds(string $email): int
{
$until = (int) Cache::get($this->resendCooldownCacheKey($email), 0);
if ($until <= 0) {
return 0;
}
return max(0, $until - time());
} }
} }

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RegistrationVerificationController extends Controller
{
public function __invoke(string $token): RedirectResponse
{
$record = DB::table('user_verification_tokens')
->where('token', $token)
->first();
if (! $record) {
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
if (now()->greaterThan($record->expires_at)) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link has expired.']);
}
$user = User::query()->find((int) $record->user_id);
if (! $user) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
$user->forceFill([
'email_verified_at' => $user->email_verified_at ?? now(),
'onboarding_step' => 'verified',
'is_active' => true,
])->save();
DB::table('user_verification_tokens')
->where('id', $record->id)
->delete();
Auth::login($user);
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
}
}

View File

@@ -0,0 +1,45 @@
<?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\View\View;
class SetupPasswordController extends Controller
{
public function create(Request $request): View
{
return view('auth.setup-password', [
'email' => (string) ($request->user()?->email ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'password' => [
'required',
'string',
'min:10',
'regex:/\d/',
'regex:/[^\w\s]/',
'confirmed',
],
], [
'password.min' => 'Your password must be at least 10 characters.',
'password.regex' => 'Your password must include at least one number and one symbol.',
'password.confirmed' => 'Password confirmation does not match.',
]);
$request->user()->forceFill([
'password' => Hash::make((string) $validated['password']),
'onboarding_step' => 'password',
'needs_password_reset' => false,
])->save();
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
class SetupUsernameController extends Controller
{
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
{
}
public function create(Request $request): View
{
return view('auth.setup-username', [
'username' => (string) ($request->user()?->username ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
$request->merge(['username' => $normalized]);
$validated = $request->validate([
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
'username.min' => 'Username must be at least 3 characters.',
'username.max' => 'Username must be at most 20 characters.',
]);
$candidate = (string) $validated['username'];
$user = $request->user();
$similar = UsernamePolicy::similarReserved($candidate);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
'current_username' => (string) ($user->username ?? ''),
]);
return back()
->withInput()
->with('status', 'Your request has been submitted for manual username review.')
->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
DB::transaction(function () use ($user, $candidate): void {
$oldUsername = (string) ($user->username ?? '');
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => strtolower($oldUsername),
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => strtolower($oldUsername)],
[
'new_username' => strtolower($candidate),
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
$user->forceFill([
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
])->save();
});
return redirect('/@' . strtolower($candidate));
}
}

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
if (empty($data['topics']) || count($data['topics']) === 0) {
try {
$categories = \App\Models\ForumCategory::query()
->withCount(['threads as num_subtopics'])
->orderBy('position')
->orderBy('id')
->get();
$topics = $categories->map(function ($category) {
$threadIds = \App\Models\ForumThread::where('category_id', $category->id)->pluck('id');
return (object) [
'topic_id' => $category->id,
'topic' => $category->name,
'discuss' => null,
'last_update' => \App\Models\ForumThread::where('category_id', $category->id)->max('last_post_at'),
'num_posts' => $threadIds->isEmpty() ? 0 : \App\Models\ForumPost::whereIn('thread_id', $threadIds)->count(),
'num_subtopics' => (int) ($category->num_subtopics ?? 0),
];
});
$data['topics'] = $topics;
} catch (\Throwable $e) {
// keep legacy response
}
}
return view('community.forum.index', $data);
}
public function topic(Request $request, $topic_id, $slug = null)
{
// Redirect to canonical slug when possible
try {
$thread = \App\Models\ForumThread::find((int) $topic_id);
if ($thread && !empty($thread->slug)) {
$correct = $thread->slug;
if ($slug !== $correct) {
$qs = $request->getQueryString();
$url = route('legacy.forum.topic', ['topic_id' => $topic_id, 'slug' => $correct]);
if ($qs) $url .= '?' . $qs;
return redirect($url, 301);
}
}
} catch (\Throwable $e) {
// ignore
}
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
// fallback to new forum tables if migration already ran
try {
$thread = \App\Models\ForumThread::with(['posts.user'])->find((int) $topic_id);
if ($thread) {
$posts = \App\Models\ForumPost::where('thread_id', $thread->id)->orderBy('created_at')->get();
$data = [
'type' => 'posts',
'thread' => $thread,
'posts' => $posts,
'page_title' => $thread->title ?? 'Forum',
];
}
} catch (\Throwable $e) {
// ignore and fall through to placeholder
}
}
if (! $data) {
return view('shared.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('community.forum.topic', $data);
}
return view('community.forum.posts', $data);
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Http\Controllers\Forum;
use App\Http\Controllers\Controller;
use App\Models\ForumCategory;
use App\Models\ForumPost;
use App\Models\ForumPostReport;
use App\Models\ForumThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
class ForumController extends Controller
{
public function index()
{
$categories = Cache::remember('forum:index:categories:v1', now()->addMinutes(5), function () {
return ForumCategory::query()
->select(['id', 'name', 'slug', 'parent_id', 'position'])
->roots()
->ordered()
->withForumStats()
->get()
->map(function (ForumCategory $category) {
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'thread_count' => (int) ($category->thread_count ?? 0),
'post_count' => (int) ($category->post_count ?? 0),
'last_activity_at' => $category->lastThread?->last_post_at ?? $category->lastThread?->updated_at,
'preview_image' => $category->preview_image,
];
});
});
$data = [
'categories' => $categories,
'page_title' => 'Forum',
'page_meta_description' => 'Skinbase forum discussions.',
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
];
return view('forum.index', $data);
}
public function showCategory(Request $request, ForumCategory $category)
{
$subtopics = ForumThread::query()
->where('category_id', $category->id)
->withCount('posts')
->with('user:id,name')
->orderByDesc('is_pinned')
->orderByDesc('last_post_at')
->orderByDesc('id')
->paginate(50)
->withQueryString();
$subtopics->getCollection()->transform(function (ForumThread $item) {
return (object) [
'topic_id' => $item->id,
'topic' => $item->title,
'discuss' => $item->content,
'post_date' => $item->created_at,
'last_update' => $item->last_post_at ?? $item->created_at,
'uname' => $item->user?->name,
'num_posts' => (int) ($item->posts_count ?? 0),
];
});
$topic = (object) [
'topic_id' => $category->id,
'topic' => $category->name,
'discuss' => null,
];
return view('forum.community.topic', [
'type' => 'subtopics',
'topic' => $topic,
'subtopics' => $subtopics,
'category' => $category,
'page_title' => $category->name,
'page_meta_description' => 'Forum section: ' . $category->name,
'page_meta_keywords' => 'forum, section, skinbase',
]);
}
public function showThread(Request $request, ForumThread $thread, ?string $slug = null)
{
if (! empty($thread->slug) && $slug !== $thread->slug) {
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug], 301);
}
$thread->loadMissing([
'category:id,name,slug',
'user:id,name',
'user.profile:user_id,avatar_hash',
]);
$threadMeta = Cache::remember(
'forum:thread:meta:v1:' . $thread->id . ':' . ($thread->updated_at?->timestamp ?? 0),
now()->addMinutes(5),
fn () => [
'category' => $thread->category,
'author' => $thread->user,
]
);
$sort = strtolower((string) $request->query('sort', 'asc')) === 'desc' ? 'desc' : 'asc';
$opPost = ForumPost::query()
->where('thread_id', $thread->id)
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
])
->orderBy('created_at', 'asc')
->orderBy('id', 'asc')
->first();
$posts = ForumPost::query()
->where('thread_id', $thread->id)
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
])
->orderBy('created_at', $sort)
->paginate(50)
->withQueryString();
$replyCount = max((int) ForumPost::query()->where('thread_id', $thread->id)->count() - 1, 0);
$attachments = collect($opPost?->attachments ?? [])
->merge($posts->getCollection()->flatMap(fn (ForumPost $post) => $post->attachments ?? []))
->values();
$quotedPost = null;
$quotePostId = (int) $request->query('quote', 0);
if ($quotePostId > 0) {
$quotedPost = ForumPost::query()
->where('thread_id', $thread->id)
->with('user:id,name')
->find($quotePostId);
}
$replyPrefill = old('content');
if ($replyPrefill === null && $quotedPost) {
$quotedAuthor = (string) ($quotedPost->user?->name ?? 'Anonymous');
$quoteText = trim(strip_tags((string) $quotedPost->content));
$quoteText = preg_replace('/\s+/', ' ', $quoteText) ?? $quoteText;
$quoteSnippet = Str::limit($quoteText, 300);
$replyPrefill = '[quote=' . $quotedAuthor . ']'
. $quoteSnippet
. '[/quote]'
. "\n\n";
}
return view('forum.thread.show', [
'thread' => $thread,
'category' => $threadMeta['category'] ?? $thread->category,
'author' => $threadMeta['author'] ?? $thread->user,
'opPost' => $opPost,
'posts' => $posts,
'attachments' => $attachments,
'reply_count' => $replyCount,
'quoted_post' => $quotedPost,
'reply_prefill' => $replyPrefill,
'sort' => $sort,
'page_title' => $thread->title,
'page_meta_description' => 'Forum thread: ' . $thread->title,
'page_meta_keywords' => 'forum, thread, skinbase',
]);
}
public function createThreadForm(ForumCategory $category)
{
return view('forum.community.new-thread', [
'category' => $category,
'page_title' => 'New thread',
]);
}
public function storeThread(Request $request, ForumCategory $category)
{
$user = Auth::user();
abort_unless($user, 403);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'min:2'],
]);
$baseSlug = Str::slug((string) $validated['title']);
$slug = $baseSlug ?: ('thread-' . time());
$counter = 2;
while (ForumThread::where('slug', $slug)->exists()) {
$slug = ($baseSlug ?: 'thread') . '-' . $counter;
$counter++;
}
$thread = ForumThread::create([
'category_id' => $category->id,
'user_id' => (int) $user->id,
'title' => $validated['title'],
'slug' => $slug,
'content' => $validated['content'],
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'public',
'last_post_at' => now(),
]);
ForumPost::create([
'thread_id' => $thread->id,
'user_id' => (int) $user->id,
'content' => $validated['content'],
'is_edited' => false,
'edited_at' => null,
]);
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
}
public function reply(Request $request, ForumThread $thread)
{
$user = Auth::user();
abort_unless($user, 403);
abort_if($thread->is_locked, 423, 'Thread is locked.');
$validated = $request->validate([
'content' => ['required', 'string', 'min:2'],
]);
ForumPost::create([
'thread_id' => $thread->id,
'user_id' => (int) $user->id,
'content' => $validated['content'],
'is_edited' => false,
'edited_at' => null,
]);
$thread->last_post_at = now();
$thread->save();
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
}
public function editPostForm(ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
return view('forum.community.edit-post', [
'post' => $post,
'thread' => $post->thread,
'page_title' => 'Edit post',
]);
}
public function updatePost(Request $request, ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
$validated = $request->validate([
'content' => ['required', 'string', 'min:2'],
]);
$post->content = $validated['content'];
$post->is_edited = true;
$post->edited_at = now();
$post->save();
return redirect()->route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $post->thread?->slug]);
}
public function reportPost(Request $request, ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_if((int) $post->user_id === (int) $user->id, 422, 'You cannot report your own post.');
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:500'],
]);
ForumPostReport::query()->updateOrCreate(
[
'post_id' => (int) $post->id,
'reporter_user_id' => (int) $user->id,
],
[
'thread_id' => (int) $post->thread_id,
'reason' => $validated['reason'] ?? null,
'status' => 'open',
'source_url' => (string) $request->headers->get('referer', ''),
'reported_at' => now(),
]
);
return back()->with('status', 'Post reported. Thank you for helping moderate the forum.');
}
public function lockThread(ForumThread $thread)
{
$thread->is_locked = true;
$thread->save();
return back();
}
public function unlockThread(ForumThread $thread)
{
$thread->is_locked = false;
$thread->save();
return back();
}
public function pinThread(ForumThread $thread)
{
$thread->is_pinned = true;
$thread->save();
return back();
}
public function unpinThread(ForumThread $thread)
{
$thread->is_pinned = false;
$thread->save();
return back();
}
}

View File

@@ -1,38 +0,0 @@
<?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

@@ -255,133 +255,6 @@ class LegacyController extends Controller
)); ));
} }
public function forumIndex()
{
$page_title = 'Forum';
$page_meta_description = 'Skinbase forum threads.';
$page_meta_keywords = 'forum, discussions, topics, skinbase';
try {
$topics = DB::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::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::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::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::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. * Fetch featured artworks with graceful fallbacks.
*/ */
@@ -437,19 +310,16 @@ class LegacyController extends Controller
private function forumNews(): array private function forumNews(): array
{ {
try { try {
return DB::table('forum_topics as t1') return DB::table('forum_threads as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') ->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->select( ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
't1.topic_id', ->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
't1.topic', ->whereNull('t1.deleted_at')
't1.views', ->where(function ($query) {
't1.post_date', $query->where('t1.category_id', 2876)
't1.preview', ->orWhereIn('c.slug', ['news', 'forum-news']);
't2.uname' })
) ->orderByDesc('t1.created_at')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
->limit(8) ->limit(8)
->get() ->get()
->toArray(); ->toArray();
@@ -487,17 +357,25 @@ class LegacyController extends Controller
private function latestForumActivity(): array private function latestForumActivity(): array
{ {
try { try {
return DB::table('forum_topics as t1') return DB::table('forum_threads as t1')
->select( ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
't1.topic_id', ->leftJoin('forum_posts as p', function ($join) {
't1.topic', $join->on('p.thread_id', '=', 't1.id')
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') ->whereNull('p.deleted_at');
) })
->where('t1.root_id', '<>', 0) ->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
->where('t1.root_id', '<>', 2876) ->whereNull('t1.deleted_at')
->where('t1.privilege', '<', 4) ->where(function ($query) {
->orderByDesc('t1.last_update') $query->where('t1.category_id', '<>', 2876)
->orderByDesc('t1.post_date') ->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10) ->limit(10)
->get() ->get()
->toArray(); ->toArray();

View File

@@ -4,9 +4,15 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\ProfileUpdateRequest;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -14,6 +20,49 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ProfileController extends Controller class ProfileController extends Controller
{ {
public function __construct(
private readonly ArtworkService $artworkService,
private readonly UsernameApprovalService $usernameApprovalService,
)
{
}
public function showByUsername(Request $request, string $username)
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
if (! $user) {
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$normalized])
->value('new_username');
if ($redirect) {
return redirect()->route('profile.show', ['username' => strtolower((string) $redirect)], 301);
}
abort(404);
}
if ($username !== strtolower((string) $user->username)) {
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
}
return $this->renderUserProfile($request, $user);
}
public function legacyById(Request $request, int $id, ?string $username = null)
{
$user = User::query()->findOrFail($id);
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
}
public function legacyByUsername(Request $request, string $username)
{
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
}
public function edit(Request $request): View public function edit(Request $request): View
{ {
return view('profile.edit', [ return view('profile.edit', [
@@ -33,6 +82,56 @@ class ProfileController extends Controller
$user->name = $validated['name']; $user->name = $validated['name'];
} }
if (array_key_exists('username', $validated)) {
$incomingUsername = UsernamePolicy::normalize((string) $validated['username']);
$currentUsername = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) {
$similar = UsernamePolicy::similarReserved($incomingUsername);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [
'current_username' => $currentUsername,
]);
return Redirect::back()->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
return Redirect::back()->withErrors([
'username' => "Username can only be changed once every {$cooldownDays} days.",
]);
}
$user->username = $incomingUsername;
$user->username_changed_at = now();
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $currentUsername,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($currentUsername !== '') {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $currentUsername],
[
'new_username' => $incomingUsername,
'user_id' => (int) $user->id,
'updated_at' => now(),
'created_at' => now(),
]
);
}
}
}
if (!empty($validated['email']) && empty($user->email)) { if (!empty($validated['email']) && empty($user->email)) {
$user->email = $validated['email']; $user->email = $validated['email'];
$user->email_verified_at = null; $user->email_verified_at = null;
@@ -154,4 +253,41 @@ class ProfileController extends Controller
return Redirect::to('/user')->with('status', 'password-updated'); return Redirect::to('/user')->with('status', 'password-updated');
} }
private function renderUserProfile(Request $request, User $user)
{
$isOwner = Auth::check() && Auth::id() === $user->id;
$perPage = 24;
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
->through(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',
];
});
$legacyUser = (object) [
'user_id' => $user->id,
'uname' => $user->username ?? $user->name,
'name' => $user->name,
'real_name' => $user->name,
'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
'about_me' => $user->bio ?? null,
];
return response()->view('legacy.profile', [
'user' => $legacyUser,
'artworks' => $artworks,
'page_title' => 'Profile: ' . ($legacyUser->uname ?? ''),
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
]);
}
} }

View File

@@ -20,7 +20,7 @@ class UserController extends Controller
$profile = null; $profile = null;
} }
return view('user.user', [ return view('legacy.user', [
'profile' => $profile, 'profile' => $profile,
]); ]);
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -29,26 +30,32 @@ class HomeController extends Controller
$featured = $featuredResult->getCollection()->first(); $featured = $featuredResult->getCollection()->first();
} elseif (is_array($featuredResult)) { } elseif (is_array($featuredResult)) {
$featured = $featuredResult[0] ?? null; $featured = $featuredResult[0] ?? null;
} elseif ($featuredResult instanceof Collection) {
$featured = $featuredResult->first();
} else { } else {
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult; $featured = $featuredResult;
} }
$memberFeatured = $featured; $memberFeatured = $featured;
$latestUploads = $this->artworks->getLatestArtworks(20); $latestUploads = $this->artworks->getLatestArtworks(20);
// Forum news (root forum section id 2876) // Forum news (prefer migrated legacy news category id 2876, fallback to slug)
try { try {
$forumNews = DB::table('forum_topics as t1') $forumNews = DB::table('forum_threads as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id') ->leftJoin('users as u', 't1.user_id', '=', 'u.id')
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview') ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->where('t1.root_id', 2876) ->selectRaw('t1.id as topic_id, t1.title as topic, COALESCE(u.name, ?) as uname, t1.created_at as post_date, t1.content as preview', ['Unknown'])
->where('t1.privilege', '<', 4) ->whereNull('t1.deleted_at')
->orderBy('t1.post_date', 'desc') ->where(function ($query) {
$query->where('t1.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->orderByDesc('t1.created_at')
->limit(8) ->limit(8)
->get(); ->get();
} catch (QueryException $e) { } catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading forum news', ['exception' => $e->getMessage()]); Log::warning('Forum threads table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
$forumNews = collect(); $forumNews = collect();
} }
@@ -66,19 +73,31 @@ class HomeController extends Controller
$ourNews = collect(); $ourNews = collect();
} }
// Latest forum activity (exclude rootless and news root) // Latest forum activity (exclude forum news category)
try { try {
$latestForumActivity = DB::table('forum_topics as t1') $latestForumActivity = DB::table('forum_threads as t1')
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->where('t1.root_id', '<>', 0) ->leftJoin('forum_posts as p', function ($join) {
->where('t1.root_id', '<>', 2876) $join->on('p.thread_id', '=', 't1.id')
->where('t1.privilege', '<', 4) ->whereNull('p.deleted_at');
->orderBy('t1.last_update', 'desc') })
->orderBy('t1.post_date', 'desc') ->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) as numPosts')
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', '<>', 2876)
->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10) ->limit(10)
->get(); ->get();
} catch (QueryException $e) { } catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]); Log::warning('Forum threads table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
$latestForumActivity = collect(); $latestForumActivity = collect();
} }

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$step = strtolower((string) ($user->onboarding_step ?? ''));
if ($step === 'complete') {
return $next($request);
}
$target = match ($step) {
'email' => '/login',
'verified' => '/setup/password',
'password', 'username' => '/setup/username',
default => '/setup/password',
};
if ($request->is(ltrim($target, '/'))) {
return $next($request);
}
return redirect($target);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Support\UsernamePolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NormalizeUsername
{
public function handle(Request $request, Closure $next): Response
{
$payload = $request->all();
if (array_key_exists('username', $payload)) {
$payload['username'] = UsernamePolicy::normalize((string) $payload['username']);
}
if (array_key_exists('old_username', $payload)) {
$payload['old_username'] = UsernamePolicy::normalize((string) $payload['old_username']);
}
$request->merge($payload);
return $next($request);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\User; use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -16,7 +17,7 @@ class ProfileUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'username' => ['sometimes', 'string', 'max:255'], 'username' => ['sometimes', ...UsernameRequest::rulesFor((int) $this->user()->id)],
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
@@ -42,4 +43,13 @@ class ProfileUpdateRequest extends FormRequest
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], 'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
]; ];
} }
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
} }

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UsernameRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
public function rules(): array
{
return [
'username' => self::rulesFor($this->resolveIgnoreUserId()),
];
}
/**
* @return array<int, mixed>
*/
public static function rulesFor(?int $ignoreUserId = null): array
{
return [
...self::formatRules(),
Rule::unique(User::class, 'username')->ignore($ignoreUserId),
];
}
/**
* @return array<int, mixed>
*/
public static function formatRules(): array
{
return [
'required',
'string',
'min:' . UsernamePolicy::min(),
'max:' . UsernamePolicy::max(),
'regex:' . UsernamePolicy::regex(),
Rule::notIn(UsernamePolicy::reserved()),
];
}
private function resolveIgnoreUserId(): ?int
{
$user = $this->user();
if ($user) {
return (int) $user->id;
}
$routeUserId = $this->route('id') ?? $this->route('user');
if (is_numeric($routeUserId)) {
return (int) $routeUserId;
}
return null;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RegistrationVerificationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public array $backoff = [60, 300, 900];
public function __construct(public readonly string $token)
{
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Verify your Skinbase email',
);
}
public function content(): Content
{
$appUrl = rtrim((string) config('app.url', 'http://localhost'), '/');
return new Content(
view: 'emails.registration-verification',
with: [
'verificationUrl' => url('/verify/'.$this->token),
'expiresInHours' => 24,
'supportUrl' => $appUrl . '/support',
],
);
}
public function attachments(): array
{
return [];
}
public function failed(\Throwable $exception): void
{
Log::warning('registration verification mail job failed', [
'token_prefix' => substr($this->token, 0, 12),
'message' => $exception->getMessage(),
'class' => get_class($exception),
]);
}
}

View File

@@ -2,7 +2,12 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class ForumCategory extends Model class ForumCategory extends Model
{ {
@@ -14,13 +19,77 @@ class ForumCategory extends Model
public $incrementing = true; public $incrementing = true;
public function parent() public function parent(): BelongsTo
{ {
return $this->belongsTo(ForumCategory::class, 'parent_id'); return $this->belongsTo(ForumCategory::class, 'parent_id');
} }
public function threads() public function children(): HasMany
{
return $this->hasMany(ForumCategory::class, 'parent_id');
}
public function threads(): HasMany
{ {
return $this->hasMany(ForumThread::class, 'category_id'); return $this->hasMany(ForumThread::class, 'category_id');
} }
public function postsThroughThreads(): HasManyThrough
{
return $this->hasManyThrough(
ForumPost::class,
ForumThread::class,
'category_id',
'thread_id',
'id',
'id'
);
}
public function lastThread(): HasOne
{
return $this->hasOne(ForumThread::class, 'category_id')->latestOfMany('last_post_at');
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('position')->orderBy('id');
}
public function scopeRoots(Builder $query): Builder
{
return $query->whereNull('parent_id');
}
public function scopeWithForumStats(Builder $query): Builder
{
return $query
->withCount(['threads as thread_count'])
->withCount(['postsThroughThreads as post_count'])
->with(['lastThread' => function ($relationQuery) {
$relationQuery->select([
'forum_threads.id',
'forum_threads.category_id',
'forum_threads.last_post_at',
'forum_threads.updated_at',
]);
}]);
}
public function getPreviewImageAttribute(): string
{
$slug = (string) ($this->slug ?? '');
$map = (array) config('forum.preview_images.map', []);
$default = (string) config('forum.preview_images.default', '/images/forum/default.jpg');
if ($slug !== '' && !empty($map[$slug])) {
return (string) $map[$slug];
}
if ($slug !== '') {
return '/images/forum/defaults/' . $slug . '.jpg';
}
return $default;
}
} }

View File

@@ -2,7 +2,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class ForumPost extends Model class ForumPost extends Model
@@ -18,16 +21,42 @@ class ForumPost extends Model
public $incrementing = true; public $incrementing = true;
protected $casts = [ protected $casts = [
'is_edited' => 'boolean',
'edited_at' => 'datetime', 'edited_at' => 'datetime',
]; ];
public function thread() public function thread(): BelongsTo
{ {
return $this->belongsTo(ForumThread::class, 'thread_id'); return $this->belongsTo(ForumThread::class, 'thread_id');
} }
public function attachments() public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function attachments(): HasMany
{ {
return $this->hasMany(ForumAttachment::class, 'post_id'); return $this->hasMany(ForumAttachment::class, 'post_id');
} }
public function scopeInThread(Builder $query, int $threadId): Builder
{
return $query->where('thread_id', $threadId);
}
public function scopeVisible(Builder $query): Builder
{
return $query;
}
public function scopePinned(Builder $query): Builder
{
return $query->whereHas('thread', fn (Builder $threadQuery) => $threadQuery->where('is_pinned', true));
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('created_at')->orderByDesc('id');
}
} }

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ForumPostReport extends Model
{
protected $table = 'forum_post_reports';
protected $fillable = [
'post_id',
'thread_id',
'reporter_user_id',
'reason',
'status',
'source_url',
'reported_at',
];
protected $casts = [
'reported_at' => 'datetime',
];
public function post(): BelongsTo
{
return $this->belongsTo(ForumPost::class, 'post_id');
}
public function thread(): BelongsTo
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function reporter(): BelongsTo
{
return $this->belongsTo(User::class, 'reporter_user_id');
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class ForumThread extends Model class ForumThread extends Model
@@ -18,16 +21,43 @@ class ForumThread extends Model
public $incrementing = true; public $incrementing = true;
protected $casts = [ protected $casts = [
'is_locked' => 'boolean',
'is_pinned' => 'boolean',
'last_post_at' => 'datetime', 'last_post_at' => 'datetime',
]; ];
public function category() public function category(): BelongsTo
{ {
return $this->belongsTo(ForumCategory::class, 'category_id'); return $this->belongsTo(ForumCategory::class, 'category_id');
} }
public function posts() public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function posts(): HasMany
{ {
return $this->hasMany(ForumPost::class, 'thread_id'); return $this->hasMany(ForumPost::class, 'thread_id');
} }
public function scopeVisible(Builder $query): Builder
{
return $query->where('visibility', 'public');
}
public function scopePinned(Builder $query): Builder
{
return $query->where('is_pinned', true);
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('last_post_at')->orderByDesc('id');
}
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
} }

View File

@@ -21,8 +21,13 @@ class User extends Authenticatable
* @var list<string> * @var list<string>
*/ */
protected $fillable = [ protected $fillable = [
'username',
'username_changed_at',
'onboarding_step',
'name', 'name',
'email', 'email',
'is_active',
'needs_password_reset',
'password', 'password',
'role', 'role',
]; ];
@@ -46,6 +51,7 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'username_changed_at' => 'datetime',
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
]; ];

View File

@@ -11,6 +11,9 @@ use App\Services\Upload\UploadDraftService;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Queue\Events\JobFailed;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -30,7 +33,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->configureAuthRateLimiters();
$this->configureUploadRateLimiters(); $this->configureUploadRateLimiters();
$this->configureMailFailureLogging();
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic) // Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
@@ -84,6 +89,37 @@ class AppServiceProvider extends ServiceProvider
}); });
} }
private function configureAuthRateLimiters(): void
{
RateLimiter::for('register', function (Request $request): array {
$emailKey = strtolower((string) $request->input('email', 'unknown'));
$ipLimit = (int) config('antispam.register.ip_per_minute', 20);
$emailLimit = (int) config('antispam.register.email_per_minute', 6);
return [
Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()),
Limit::perMinute($emailLimit)->by('register:email:' . $emailKey),
];
});
}
private function configureMailFailureLogging(): void
{
Event::listen(JobFailed::class, function (JobFailed $event): void {
if (! str_contains(strtolower($event->job->resolveName()), 'sendqueuedmailable')) {
return;
}
Log::warning('mail delivery failed', [
'transport' => config('mail.default'),
'job_name' => $event->job->resolveName(),
'queue' => $event->job->getQueue(),
'connection' => $event->connectionName,
'exception' => $event->exception->getMessage(),
]);
});
}
private function configureUploadRateLimiters(): void private function configureUploadRateLimiters(): void
{ {
RateLimiter::for('uploads-init', function (Request $request): array { RateLimiter::for('uploads-init', function (Request $request): array {

View File

@@ -4,7 +4,6 @@ namespace App\Services;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\ArtworkFeature;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -220,14 +219,48 @@ class ArtworkService
} }
} }
$categoryIds = $this->categoryAndDescendantIds($current);
$query = $this->browseQuery($sort) $query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($current) { ->whereHas('categories', function ($q) use ($categoryIds) {
$q->where('categories.id', $current->id); $q->whereIn('categories.id', $categoryIds);
}); });
return $query->cursorPaginate($perPage); return $query->cursorPaginate($perPage);
} }
/**
* Collect category id plus all descendant category ids.
*
* @return array<int, int>
*/
private function categoryAndDescendantIds(Category $category): array
{
$allIds = [(int) $category->id];
$frontier = [(int) $category->id];
while (! empty($frontier)) {
$children = Category::whereIn('parent_id', $frontier)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if (empty($children)) {
break;
}
$newIds = array_values(array_diff($children, $allIds));
if (empty($newIds)) {
break;
}
$allIds = array_values(array_unique(array_merge($allIds, $newIds)));
$frontier = $newIds;
}
return $allIds;
}
/** /**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type. * Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters. * Uses artwork_features table and applies public/approved/published filters.

View File

@@ -2,10 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Models\Artwork;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
@@ -120,19 +119,16 @@ class LegacyService
public function forumNews(): array public function forumNews(): array
{ {
try { try {
return DB::table('forum_topics as t1') return DB::table('forum_threads as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') ->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->select( ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
't1.topic_id', ->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
't1.topic', ->whereNull('t1.deleted_at')
't1.views', ->where(function ($query) {
't1.post_date', $query->where('t1.category_id', 2876)
't1.preview', ->orWhereIn('c.slug', ['news', 'forum-news']);
't2.uname' })
) ->orderByDesc('t1.created_at')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
->limit(8) ->limit(8)
->get() ->get()
->toArray(); ->toArray();
@@ -170,17 +166,25 @@ class LegacyService
public function latestForumActivity(): array public function latestForumActivity(): array
{ {
try { try {
return DB::table('forum_topics as t1') return DB::table('forum_threads as t1')
->select( ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
't1.topic_id', ->leftJoin('forum_posts as p', function ($join) {
't1.topic', $join->on('p.thread_id', '=', 't1.id')
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') ->whereNull('p.deleted_at');
) })
->where('t1.root_id', '<>', 0) ->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
->where('t1.root_id', '<>', 2876) ->whereNull('t1.deleted_at')
->where('t1.privilege', '<', 4) ->where(function ($query) {
->orderByDesc('t1.last_update') $query->where('t1.category_id', '<>', 2876)
->orderByDesc('t1.post_date') ->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10) ->limit(10)
->get() ->get()
->toArray(); ->toArray();
@@ -266,7 +270,7 @@ class LegacyService
$row->encoded = $encoded; $row->encoded = $encoded;
// Prefer new files.skinbase.org when possible // Prefer new files.skinbase.org when possible
try { try {
$art = \App\Models\Artwork::find($row->id); $art = Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb_url = $present['url']; $row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset']; $row->thumb_srcset = $present['srcset'];
@@ -402,126 +406,6 @@ class LegacyService
]; ];
} }
public function forumIndex()
{
try {
$topics = DB::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::table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (! $topic) {
return null;
}
try {
$subtopics = DB::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::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::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 * Fetch a single artwork by id with author and category
* Returns null on failure. * Returns null on failure.
@@ -555,7 +439,7 @@ class LegacyService
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg'; $thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
// Prefer new CDN when possible // Prefer new CDN when possible
try { try {
$art = \App\Models\Artwork::find($row->id); $art = Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$thumb_file = $present['url']; $thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null); $thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Security;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class RecaptchaVerifier
{
public function isEnabled(): bool
{
return (bool) config('services.recaptcha.enabled', false);
}
public function verify(string $token, ?string $ip = null): bool
{
if (! $this->isEnabled()) {
return true;
}
$secret = (string) config('services.recaptcha.secret', '');
if ($secret === '' || $token === '') {
return false;
}
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = Http::asForm()
->timeout((int) config('services.recaptcha.timeout', 5))
->post((string) config('services.recaptcha.verify_url'), [
'secret' => $secret,
'response' => $token,
'remoteip' => $ip,
]);
if ($response->status() < 200 || $response->status() >= 300) {
return false;
}
$payload = json_decode((string) $response->body(), true);
return (bool) data_get(is_array($payload) ? $payload : [], 'success', false);
} catch (\Throwable $e) {
Log::warning('recaptcha verification request failed', [
'message' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class UsernameApprovalService
{
public function submit(?User $user, string $username, string $context, array $payload = []): ?int
{
if (! Schema::hasTable('username_approval_requests')) {
return null;
}
$normalized = UsernamePolicy::normalize($username);
$similar = UsernamePolicy::similarReserved($normalized);
if ($similar === null) {
return null;
}
$existingId = DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('context', $context)
->where('status', 'pending')
->when($user !== null, fn ($q) => $q->where('user_id', (int) $user->id), fn ($q) => $q->whereNull('user_id'))
->value('id');
if ($existingId) {
return (int) $existingId;
}
return (int) DB::table('username_approval_requests')->insertGetId([
'user_id' => $user?->id,
'requested_username' => $normalized,
'context' => $context,
'similar_to' => $similar,
'status' => 'pending',
'payload' => $payload === [] ? null : json_encode($payload),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -19,9 +19,13 @@ class AvatarUrl
return self::default(); return self::default();
} }
$base = rtrim((string) config('cdn.avatar_url', 'https://file.skinbase.org'), '/'); $base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
return sprintf('%s/avatars/%d/%d.webp?v=%s', $base, $userId, $size, $avatarHash); // Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash}
$p1 = substr($avatarHash, 0, 2);
$p2 = substr($avatarHash, 2, 2);
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
} }
public static function default(): string public static function default(): string

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Support;
class ForumPostContent
{
public static function render(?string $raw): string
{
$content = (string) ($raw ?? '');
if ($content === '') {
return '';
}
$allowedTags = '<p><br><strong><em><b><i><u><ul><ol><li><blockquote><code><pre><a><img>';
$sanitized = strip_tags($content, $allowedTags);
$sanitized = preg_replace('/\son\w+\s*=\s*"[^"]*"/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\son\w+\s*=\s*\'[^\']*\'/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*"\s*javascript:[^"]*"/i', ' $1="#"', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*\'\s*javascript:[^\']*\'/i', ' $1="#"', $sanitized) ?? $sanitized;
$linked = preg_replace_callback(
'/(?<!["\'>])(https?:\/\/[^\s<]+)/i',
static function (array $matches): string {
$url = $matches[1] ?? '';
$escapedUrl = e($url);
return '<a href="' . $escapedUrl . '" target="_blank" rel="noopener noreferrer" class="text-sky-300 hover:text-sky-200 underline">' . $escapedUrl . '</a>';
},
$sanitized,
);
return (string) ($linked ?? $sanitized);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class UsernamePolicy
{
public static function min(): int
{
return (int) config('usernames.min', 3);
}
public static function max(): int
{
return (int) config('usernames.max', 20);
}
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_-]+$/');
}
/**
* @return array<int, string>
*/
public static function reserved(): array
{
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), (array) config('usernames.reserved', []))));
}
public static function normalize(string $value): string
{
return strtolower(trim($value));
}
public static function sanitizeLegacy(string $value): string
{
$value = Str::ascii($value);
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9_-]+/', '_', $value) ?? '';
$value = trim($value, '_-');
if ($value === '') {
return 'user';
}
return substr($value, 0, self::max());
}
public static function isReserved(string $username): bool
{
return in_array(self::normalize($username), self::reserved(), true);
}
public static function similarReserved(string $username): ?string
{
$normalized = self::normalize($username);
$reduced = self::reduceForSimilarity($normalized);
$threshold = (int) config('usernames.similarity_threshold', 2);
foreach (self::reserved() as $reserved) {
if (levenshtein($reduced, self::reduceForSimilarity($reserved)) <= $threshold) {
return $reserved;
}
}
return null;
}
public static function hasApprovedOverride(string $username, ?int $userId = null): bool
{
if (! Schema::hasTable('username_approval_requests')) {
return false;
}
$normalized = self::normalize($username);
return DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('status', 'approved')
->when($userId !== null, fn ($q) => $q->where(function ($sub) use ($userId) {
$sub->where('user_id', $userId)->orWhereNull('user_id');
}))
->exists();
}
public static function uniqueCandidate(string $base, ?int $ignoreUserId = null): string
{
$base = self::sanitizeLegacy($base);
if ($base === '' || self::isReserved($base) || self::similarReserved($base) !== null) {
$base = 'user';
}
$max = self::max();
$candidate = substr($base, 0, $max);
$suffix = 1;
while (self::exists($candidate, $ignoreUserId) || self::isReserved($candidate) || self::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
return $candidate;
}
private static function exists(string $username, ?int $ignoreUserId = null): bool
{
$query = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)]);
if ($ignoreUserId !== null) {
$query->where('id', '!=', $ignoreUserId);
}
return $query->exists();
}
private static function reduceForSimilarity(string $value): string
{
return preg_replace('/[0-9_-]+/', '', strtolower($value)) ?? strtolower($value);
}
}

View File

@@ -852,7 +852,7 @@ index b1b1cd0..9c70607 100644
return [ return [
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
+ 'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'), + 'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
]; ];
diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php
index 0b90950..bae1a02 100644 index 0b90950..bae1a02 100644

View File

@@ -18,6 +18,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([ $middleware->alias([
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
'ensure.onboarding.complete' => \App\Http\Middleware\EnsureOnboardingComplete::class,
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

9
config/antispam.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
'register' => [
'ip_per_minute' => (int) env('REGISTER_IP_PER_MINUTE', 20),
'email_per_minute' => (int) env('REGISTER_EMAIL_PER_MINUTE', 6),
'resend_cooldown_seconds' => (int) env('REGISTER_RESEND_COOLDOWN_SECONDS', 60),
],
];

View File

@@ -4,5 +4,5 @@ declare(strict_types=1);
return [ return [
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'), 'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
]; ];

View File

@@ -65,12 +65,12 @@ return [
'legacy' => [ 'legacy' => [
'driver' => 'mysql', 'driver' => 'mysql',
'url' => env('LEGACY_DB_URL'), 'url' => env('DB_LEGACY_URL', env('LEGACY_DB_URL')),
'host' => env('LEGACY_DB_HOST', env('DB_HOST', '127.0.0.1')), 'host' => env('DB_LEGACY_HOST', env('LEGACY_DB_HOST', env('DB_HOST', '127.0.0.1'))),
'port' => env('LEGACY_DB_PORT', env('DB_PORT', '3306')), 'port' => env('DB_LEGACY_PORT', env('LEGACY_DB_PORT', env('DB_PORT', '3306'))),
'database' => env('LEGACY_DB_DATABASE', 'projekti_old_skinbase'), 'database' => env('DB_LEGACY_DATABASE', env('LEGACY_DB_DATABASE', 'projekti_old_skinbase')),
'username' => env('LEGACY_DB_USERNAME', env('DB_USERNAME', 'root')), 'username' => env('DB_LEGACY_USERNAME', env('LEGACY_DB_USERNAME', env('DB_USERNAME', 'root'))),
'password' => env('LEGACY_DB_PASSWORD', env('DB_PASSWORD', '')), 'password' => env('DB_LEGACY_PASSWORD', env('LEGACY_DB_PASSWORD', env('DB_PASSWORD', ''))),
'unix_socket' => env('LEGACY_DB_SOCKET', ''), 'unix_socket' => env('LEGACY_DB_SOCKET', ''),
'charset' => env('LEGACY_DB_CHARSET', 'utf8mb4'), 'charset' => env('LEGACY_DB_CHARSET', 'utf8mb4'),
'collation' => env('LEGACY_DB_COLLATION', 'utf8mb4_unicode_ci'), 'collation' => env('LEGACY_DB_COLLATION', 'utf8mb4_unicode_ci'),

10
config/forum.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
return [
'preview_images' => [
'default' => '/images/forum/default.jpg',
'map' => [
// 'announcements' => '/images/forum/defaults/announcements.jpg',
],
],
];

View File

@@ -39,4 +39,12 @@ return [
'driver' => env('IMAGE_DRIVER', 'gd'), 'driver' => env('IMAGE_DRIVER', 'gd'),
], ],
'recaptcha' => [
'enabled' => env('RECAPTCHA_ENABLED', false),
'site_key' => env('RECAPTCHA_SITE_KEY'),
'secret' => env('RECAPTCHA_SECRET_KEY'),
'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'),
'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5),
],
]; ];

44
config/usernames.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
return [
'min' => 3,
'max' => 20,
'regex' => '/^[a-zA-Z0-9_-]+$/',
'rename_cooldown_days' => 90,
'similarity_threshold' => 2,
'reserved' => [
'admin',
'root',
'support',
'staff',
'moderator',
'mod',
'system',
'api',
'www',
'mail',
'ftp',
'skinbase',
'official',
'help',
'security',
'login',
'register',
'auth',
'dashboard',
'settings',
'forum',
'gallery',
'upload',
'search',
'static',
'cdn',
'assets',
'images',
'profile',
'user',
'users',
],
];

View File

@@ -23,7 +23,17 @@ class UserFactory extends Factory
*/ */
public function definition(): array public function definition(): array
{ {
$username = Str::lower(Str::slug(fake()->unique()->userName(), '-'));
$username = preg_replace('/[^a-z0-9_-]/', '-', $username) ?: 'user';
$username = substr(trim($username, '-_'), 0, 20);
if ($username === '') {
$username = 'user' . fake()->unique()->numberBetween(100, 99999);
}
return [ return [
'username' => $username,
'username_changed_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('forum_post_reports', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete();
$table->foreignId('thread_id')->constrained('forum_threads')->cascadeOnDelete();
$table->foreignId('reporter_user_id')->constrained('users')->cascadeOnDelete();
$table->string('status', 20)->default('open');
$table->string('reason', 500)->nullable();
$table->string('source_url', 1024)->nullable();
$table->timestamp('reported_at')->nullable();
$table->timestamps();
$table->unique(['post_id', 'reporter_user_id'], 'forum_post_reports_unique_reporter_per_post');
$table->index(['thread_id', 'status']);
$table->index('reported_at');
});
}
public function down(): void
{
Schema::dropIfExists('forum_post_reports');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
//
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
//
});
}
};

View File

@@ -0,0 +1,110 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (Schema::hasColumn('users', 'username')) {
$this->normalizeExistingUsernames();
}
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'username')) {
$table->string('username', 20)->nullable()->change();
}
if (! Schema::hasColumn('users', 'username_changed_at')) {
$table->timestamp('username_changed_at')->nullable()->after('username');
}
});
$sm = Schema::getConnection()->getSchemaBuilder();
$indexes = $sm->getIndexes('users');
$hasUsernameUnique = collect($indexes)->contains(function ($index): bool {
$columns = array_map('strtolower', (array) ($index['columns'] ?? []));
return (bool) ($index['unique'] ?? false) && $columns === ['username'];
});
if (! $hasUsernameUnique && Schema::hasColumn('users', 'username')) {
Schema::table('users', function (Blueprint $table) {
$table->unique('username', 'users_username_unique');
});
}
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'username_changed_at')) {
$table->dropColumn('username_changed_at');
}
if (Schema::hasColumn('users', 'username')) {
$table->string('username', 80)->nullable()->change();
}
});
}
private function normalizeExistingUsernames(): void
{
$rows = DB::table('users')
->select('id', 'username')
->whereNotNull('username')
->orderBy('id')
->get();
if ($rows->isEmpty()) {
return;
}
$resolved = [];
$used = [];
foreach ($rows as $row) {
$raw = strtolower(trim((string) $row->username));
$base = preg_replace('/[^a-z0-9_-]+/', '_', $raw) ?? '';
$base = trim($base, '_-');
if ($base === '') {
$base = 'user' . (int) $row->id;
}
$base = substr($base, 0, 20);
if ($base === '') {
$base = 'user';
}
$candidate = $base;
$suffix = 1;
while (isset($used[$candidate])) {
$suffixValue = (string) $suffix;
$prefixLen = max(1, 20 - strlen($suffixValue));
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
$suffix++;
}
$used[$candidate] = true;
$resolved[(int) $row->id] = $candidate;
}
foreach ($rows as $row) {
DB::table('users')
->where('id', (int) $row->id)
->update(['username' => 'tmpu' . (int) $row->id]);
}
foreach ($rows as $row) {
$final = $resolved[(int) $row->id] ?? null;
if ($final === null) {
continue;
}
DB::table('users')
->where('id', (int) $row->id)
->update(['username' => $final]);
}
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('username_history', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('old_username', 20);
$table->timestamp('changed_at');
$table->timestamps();
$table->index(['user_id', 'changed_at']);
$table->index('old_username');
});
Schema::create('username_redirects', function (Blueprint $table) {
$table->id();
$table->string('old_username', 20)->unique();
$table->string('new_username', 20);
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index('new_username');
});
}
public function down(): void
{
Schema::dropIfExists('username_redirects');
Schema::dropIfExists('username_history');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('username_approval_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('requested_username', 20);
$table->string('context', 32)->default('unknown');
$table->string('similar_to', 20)->nullable();
$table->string('status', 20)->default('pending');
$table->json('payload')->nullable();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->text('review_note')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
$table->index(['requested_username', 'status']);
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('username_approval_requests');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
if (! Schema::hasColumn('users', 'email_verified_at')) {
$table->timestamp('email_verified_at')->nullable()->after('email');
}
if (! Schema::hasColumn('users', 'onboarding_step')) {
$table->enum('onboarding_step', ['email', 'verified', 'password', 'username', 'complete'])
->nullable()
->after('email_verified_at');
}
if (! Schema::hasColumn('users', 'username_changed_at')) {
$table->timestamp('username_changed_at')->nullable()->after('username');
}
});
if (! Schema::hasTable('user_verification_tokens')) {
Schema::create('user_verification_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('token', 128)->unique();
$table->timestamp('expires_at');
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
}
}
public function down(): void
{
if (Schema::hasTable('user_verification_tokens')) {
Schema::drop('user_verification_tokens');
}
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'onboarding_step')) {
$table->dropColumn('onboarding_step');
}
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (! Schema::hasColumn('users', 'username')) {
return;
}
DB::table('users')
->whereNotNull('username')
->update(['username' => DB::raw('LOWER(username)')]);
$driver = DB::getDriverName();
try {
if ($driver === 'mysql') {
DB::statement(
'ALTER TABLE users ADD CONSTRAINT users_username_lowercase_check CHECK (username IS NULL OR BINARY username = LOWER(username))'
);
} elseif ($driver === 'pgsql') {
DB::statement(
'ALTER TABLE users ADD CONSTRAINT users_username_lowercase_check CHECK (username IS NULL OR username = LOWER(username))'
);
}
} catch (\Throwable $e) {
if (! str_contains(strtolower($e->getMessage()), 'already exists')) {
throw $e;
}
}
}
public function down(): void
{
$driver = DB::getDriverName();
try {
if ($driver === 'mysql') {
DB::statement('ALTER TABLE users DROP CHECK users_username_lowercase_check');
} elseif ($driver === 'pgsql') {
DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_lowercase_check');
}
} catch (\Throwable $e) {
if (! str_contains(strtolower($e->getMessage()), 'check') && ! str_contains(strtolower($e->getMessage()), 'constraint')) {
throw $e;
}
}
}
};

View File

@@ -4,7 +4,7 @@ This project serves avatars from the avatar CDN domain.
## Required env variables ## Required env variables
- `AVATAR_CDN_URL=https://file.skinbase.org` - `AVATAR_CDN_URL=https://files.skinbase.org`
- `AVATAR_DISK=s3` (production) - `AVATAR_DISK=s3` (production)
- `AVATAR_WEBP_QUALITY=85` - `AVATAR_WEBP_QUALITY=85`
@@ -12,7 +12,7 @@ This project serves avatars from the avatar CDN domain.
Avatars are rendered via: Avatars are rendered via:
- `https://file.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}` - `https://files.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}`
Sizes generated server-side: Sizes generated server-side:

View File

@@ -492,13 +492,50 @@ if ($('.switch').length && $.fn.bootstrapSwitch) {
} }
/**** Datepicker ****/ /**** Datepicker ****/
if ($('.datepicker').length && $.fn.datepicker) { /**
$('.datepicker').each(function () { * Datepicker initialization helper.
var datepicker_inline = $(this).data('inline') ? $(this).data('inline') : false; * Ensures any newly added .datepicker inside a popup/modal is initialized.
$(this).datepicker({ */
inline: datepicker_inline function initDatepickers(root) {
if (!$.fn.datepicker) return;
root = root || document;
var $root = $(root instanceof jQuery ? root[0] : root);
// Initialize datepickers that don't yet have the jQuery UI marker class
$root.find('.datepicker').each(function () {
var $el = $(this);
if ($el.hasClass('hasDatepicker')) return;
var datepicker_inline = $el.data('inline') ? $el.data('inline') : false;
$el.datepicker({ inline: datepicker_inline });
});
}
// Initialize on initial page load
initDatepickers(document);
// Re-initialize when Bootstrap modals are shown (common popup pattern)
$(document).on('shown.bs.modal', '.modal', function () {
initDatepickers(this);
});
// Observe DOM mutations and initialize datepickers for added nodes (covers custom popups)
if (window.MutationObserver) {
var _dpObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
m.addedNodes && Array.prototype.forEach.call(m.addedNodes, function (node) {
if (node.nodeType !== 1) return;
// If the added node itself has .datepicker or contains them, init
var $node = $(node);
if ($node.is('.datepicker') || $node.find('.datepicker').length) {
initDatepickers(node);
}
});
}); });
}); });
try {
_dpObserver.observe(document.body, { childList: true, subtree: true });
} catch (e) {
// ignore observer errors on very old browsers
}
} }
/**** Datetimepicker ****/ /**** Datetimepicker ****/

View File

@@ -0,0 +1,6 @@
import React from 'react'
import AdminUsernameQueue from '../../components/admin/AdminUsernameQueue'
export default function UsernameQueuePage() {
return <AdminUsernameQueue />
}

View File

@@ -1,4 +1,5 @@
import './bootstrap'; import './bootstrap';
import './username-availability';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import React from 'react'; import React from 'react';

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react'
export default function AdminUsernameQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/usernames/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load username moderation queue.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPending()
}, [])
const moderate = async (id, action) => {
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/usernames/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} username request.`)
}
}
return (
<section aria-label="Username moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Username Approvals</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
Refresh
</button>
</div>
{loading ? <p role="status" className="text-sm text-white/70">Loading</p> : null}
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending username requests.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.requested_username}</div>
<div className="mt-1 text-xs text-white/65">Request #{item.id} · {item.context}</div>
{item.similar_to ? <div className="mt-1 text-xs text-amber-200">Similar to reserved: {item.similar_to}</div> : null}
</div>
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for request ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Review note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,62 @@
const debounce = (fn, wait = 350) => {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), wait)
}
}
const setStatus = (target, message, tone = 'neutral') => {
if (!target) return
target.textContent = message || ''
target.classList.remove('text-green-600', 'text-red-600', 'text-gray-500')
if (tone === 'success') target.classList.add('text-green-600')
else if (tone === 'error') target.classList.add('text-red-600')
else target.classList.add('text-gray-500')
}
const initUsernameAvailability = () => {
const fields = document.querySelectorAll('[data-username-field="true"]')
fields.forEach((field) => {
const url = field.getAttribute('data-availability-url') || '/api/username/availability'
const statusId = field.getAttribute('data-availability-target')
const statusEl = statusId ? document.getElementById(statusId) : null
const check = debounce(async () => {
const raw = String(field.value || '')
const username = raw.trim().toLowerCase()
if (!username) {
setStatus(statusEl, '')
return
}
setStatus(statusEl, 'Checking availability...')
try {
const response = await window.axios.get(url, { params: { username } })
const data = response?.data || {}
if (data.available) {
setStatus(statusEl, `Available: ${data.normalized || username}`, 'success')
} else {
setStatus(statusEl, `Taken: ${data.normalized || username}`, 'error')
}
} catch (error) {
if (error?.response?.status === 422) {
const message = error?.response?.data?.errors?.username?.[0] || 'Invalid username.'
setStatus(statusEl, message, 'error')
return
}
setStatus(statusEl, 'Could not check availability right now.', 'error')
}
})
field.addEventListener('input', check)
})
}
document.addEventListener('DOMContentLoaded', initUsernameAvailability)

View File

@@ -54,7 +54,7 @@
</ul> </ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div> <div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2"> <ul class="space-y-2 pr-2">
@foreach($subcategories as $sub) @foreach($subcategories as $sub)
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li> <li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
@endforeach @endforeach

View File

@@ -1,27 +1,31 @@
<x-guest-layout> @extends('layouts.nova')
<div class="mb-4 text-sm text-gray-600">
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }} @section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Confirm Password</h1>
<p class="mt-2 text-sm text-sb-muted">Please confirm your password before continuing.</p>
<form method="POST" action="{{ route('password.confirm') }}" class="mt-4">
@csrf
<div>
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</div> </div>
</div>
<form method="POST" action="{{ route('password.confirm') }}"> @endsection
@csrf
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>

View File

@@ -1,25 +1,28 @@
<x-guest-layout> @extends('layouts.nova')
<div class="mb-4 text-sm text-gray-600">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} @section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
<p class="mt-2 text-sm text-sb-muted">Enter your email and we'll send a link to reset your password.</p>
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="mt-4">
@csrf
<div>
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
</form>
</div> </div>
</div>
<!-- Session Status --> @endsection
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>

View File

@@ -1,47 +1,49 @@
<x-guest-layout> @extends('layouts.nova')
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('login') }}"> @section('content')
@csrf <div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Log in</h1>
<p class="mt-2 text-sm text-sb-muted">Sign in to continue to your Skinbase account.</p>
<!-- Email Address --> <x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password --> <form method="POST" action="{{ route('login') }}" class="mt-4 space-y-4">
<div class="mt-4"> @csrf
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" <div>
type="password" <x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
name="password" <x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
required autocomplete="current-password" /> <x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password')" class="mt-2" /> <div>
</div> <x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Remember Me --> <div class="block mt-1">
<div class="block mt-4"> <label for="remember_me" class="inline-flex items-center">
<label for="remember_me" class="inline-flex items-center"> <input id="remember_me" type="checkbox" class="rounded border-sb-line bg-black/20 text-sb-blue shadow-sm focus:ring-sb-blue" name="remember">
<input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember"> <span class="ms-2 text-sm text-sb-muted">{{ __('Remember me') }}</span>
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span> </label>
</label> </div>
</div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-between pt-2">
@if (Route::has('password.request')) @if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}"> <a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }} {{ __('Forgot your password?') }}
</a> </a>
@endif @else
<span></span>
@endif
<x-primary-button class="ms-3"> <x-primary-button class="justify-center">
{{ __('Log in') }} {{ __('Log in') }}
</x-primary-button> </x-primary-button>
</div> </div>
</form> </form>
</x-guest-layout> </div>
</div>
@endsection

View File

@@ -0,0 +1,27 @@
@php
$steps = [
'email' => 'Email',
'verified' => 'Verified',
'password' => 'Password',
'complete' => 'Username',
];
$currentIndex = array_search($currentStep ?? 'email', array_keys($steps), true);
if ($currentIndex === false) {
$currentIndex = 0;
}
@endphp
<div class="mb-6">
<div class="flex items-center justify-between text-xs sm:text-sm text-gray-600">
@foreach($steps as $key => $label)
@php $idx = array_search($key, array_keys($steps), true); @endphp
<span class="{{ $idx <= $currentIndex ? 'text-gray-900 font-semibold' : '' }}">
{{ $label }}
</span>
@endforeach
</div>
<div class="mt-2 h-2 w-full bg-gray-200 rounded-full overflow-hidden">
<div class="h-full bg-gray-900 rounded-full" style="width: {{ (($currentIndex + 1) / count($steps)) * 100 }}%"></div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<div class="mb-4 text-sm text-sb-muted">
<p class="font-medium text-white">Check your inbox</p>
@if($email !== '')
<p class="mt-1">We sent a verification link to <strong class="text-white">{{ $email }}</strong>.</p>
<p class="mt-1">Click the link in that email to continue setup.</p>
@else
<p class="mt-1">Enter your email to resend verification if needed.</p>
@endif
</div>
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('register.resend') }}" class="space-y-4" id="resend-form" data-resend-seconds="{{ (int) $resendSeconds }}">
@csrf
<div>
<x-input-label for="email" value="Email" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $email)" required autocomplete="email" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">Back to login</a>
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('register', ['email' => $email]) }}">Change email</a>
</div>
<x-primary-button id="resend-btn" class="justify-center" type="submit">
Resend verification email
</x-primary-button>
</div>
<p id="resend-timer" class="text-xs text-sb-muted"></p>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('resend-form');
const button = document.getElementById('resend-btn');
const timerText = document.getElementById('resend-timer');
if (!form || !button || !timerText) return;
let remaining = parseInt(form.dataset.resendSeconds || '0', 10);
const render = () => {
if (remaining > 0) {
button.setAttribute('disabled', 'disabled');
timerText.textContent = `You can resend in ${remaining}s.`;
} else {
button.removeAttribute('disabled');
timerText.textContent = 'Did not receive it? You can resend now.';
}
};
render();
if (remaining <= 0) return;
const interval = setInterval(() => {
remaining -= 1;
render();
if (remaining <= 0) {
clearInterval(interval);
}
}, 1000);
});
</script>
@endsection

View File

@@ -1,52 +1,41 @@
<x-guest-layout> @extends('layouts.nova')
<form method="POST" action="{{ route('register') }}">
@csrf
<!-- Name --> @section('content')
<div> <div class="px-4 py-8 md:px-6 md:py-10">
<x-input-label for="name" :value="__('Name')" /> <div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" /> <h1 class="text-2xl font-semibold text-white">Create Account</h1>
<x-input-error :messages="$errors->get('name')" class="mt-2" /> <p class="mt-2 text-sm text-sb-muted">Start with your email. You will set your password and username after verification.</p>
</div>
<!-- Email Address --> <form method="POST" action="{{ route('register') }}" class="mt-6 space-y-4">
<div class="mt-4"> @csrf
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password --> <div style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">
<div class="mt-4"> <label for="website">Website</label>
<x-input-label for="password" :value="__('Password')" /> <input id="website" type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<x-text-input id="password" class="block mt-1 w-full" <!-- Email Address -->
type="password" <div>
name="password" <x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
required autocomplete="new-password" /> <x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $prefillEmail ?? '')" required autofocus autocomplete="email" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password')" class="mt-2" /> @if(config('services.recaptcha.enabled'))
</div> <input type="hidden" name="g-recaptcha-response" value="{{ old('g-recaptcha-response') }}" />
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
<!-- Confirm Password --> <div class="flex items-center justify-between pt-2">
<div class="mt-4"> <a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" /> {{ __('Already registered?') }}
</a>
<x-text-input id="password_confirmation" class="block mt-1 w-full" <x-primary-button class="justify-center">
type="password" {{ __('Register') }}
name="password_confirmation" required autocomplete="new-password" /> </x-primary-button>
</div>
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" /> </form>
</div> </div>
</div>
<div class="flex items-center justify-end mt-4"> @endsection
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
{{ __('Already registered?') }}
</a>
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>

View File

@@ -1,39 +1,48 @@
<x-guest-layout> @extends('layouts.nova')
<form method="POST" action="{{ route('password.store') }}">
@csrf
<!-- Password Reset Token --> @section('content')
<input type="hidden" name="token" value="{{ $request->route('token') }}"> <div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
<p class="mt-2 text-sm text-sb-muted">Choose a new password for your account.</p>
<!-- Email Address --> <form method="POST" action="{{ route('password.store') }}" class="mt-4">
<div> @csrf
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password --> <!-- Password Reset Token -->
<div class="mt-4"> <input type="hidden" name="token" value="{{ $request->route('token') }}">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password --> <!-- Email Address -->
<div class="mt-4"> <div>
<x-input-label for="password_confirmation" :value="__('Confirm Password')" /> <x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-text-input id="password_confirmation" class="block mt-1 w-full" <!-- Password -->
type="password" <div class="mt-4">
name="password_confirmation" required autocomplete="new-password" /> <x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" /> <!-- Confirm Password -->
</div> <div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
<div class="flex items-center justify-end mt-4"> <x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
<x-primary-button> type="password"
{{ __('Reset Password') }} name="password_confirmation" required autocomplete="new-password" />
</x-primary-button>
</div> <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</form> </div>
</x-guest-layout>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,44 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Set Your Password</h1>
<div class="mt-4 text-white/90">
@include('auth.partials.onboarding-progress', ['currentStep' => 'verified'])
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
<p class="mb-4 text-sm text-sb-muted">
{{ __('Create a password for ') }}<strong>{{ $email }}</strong>
</p>
<form method="POST" action="{{ route('setup.password.store') }}">
@csrf
<div>
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<p class="mt-2 text-xs text-sb-muted">{{ __('Minimum 10 characters, include at least one number and one symbol.') }}</p>
</div>
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
<x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password_confirmation" required autocomplete="new-password" />
</div>
<div class="mt-6 flex justify-end">
<x-primary-button class="w-full sm:w-auto justify-center">
{{ __('Continue') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,52 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Choose Username</h1>
<div class="mt-4 text-white/90">
@include('auth.partials.onboarding-progress', ['currentStep' => 'password'])
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('setup.username.store') }}">
@csrf
<div>
<x-input-label for="username" :value="__('Username')" class="text-sb-muted" />
<x-text-input
id="username"
class="block mt-1 w-full bg-black/20 border-sb-line text-white"
type="text"
name="username"
:value="old('username', $username)"
required
autocomplete="username"
data-username-field="true"
data-availability-url="{{ route('api.username.availability') }}"
data-availability-target="setup-username-availability"
/>
<p id="setup-username-availability" class="mt-1 text-xs text-sb-muted"></p>
<x-input-error :messages="$errors->get('username')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-primary-button class="w-full sm:w-auto justify-center">
{{ __('Complete Setup') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -1,31 +1,36 @@
<x-guest-layout> @extends('layouts.nova')
<div class="mb-4 text-sm text-gray-600">
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
</div>
@if (session('status') == 'verification-link-sent') @section('content')
<div class="mb-4 font-medium text-sm text-green-600"> <div class="px-4 py-8 md:px-6 md:py-10">
{{ __('A new verification link has been sent to the email address you provided during registration.') }} <div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
</div> <h1 class="text-2xl font-semibold text-white">Verify Your Email</h1>
@endif <p class="mt-2 text-sm text-sb-muted">Before getting started, please verify your email address by clicking the link we sent you.</p>
<div class="mt-4 flex items-center justify-between"> @if (session('status') == 'verification-link-sent')
<form method="POST" action="{{ route('verification.send') }}"> <div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
@csrf {{ __('A new verification link has been sent to the email address you provided during registration.') }}
<div>
<x-primary-button>
{{ __('Resend Verification Email') }}
</x-primary-button>
</div> </div>
</form> @endif
<form method="POST" action="{{ route('logout') }}"> <div class="mt-4 flex items-center justify-between">
@csrf <form method="POST" action="{{ route('verification.send') }}">
@csrf
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <div>
{{ __('Log Out') }} <x-primary-button>
</button> {{ __('Resend Verification Email') }}
</form> </x-primary-button>
</div>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="underline text-sm text-sb-muted hover:text-white rounded-md">
{{ __('Log Out') }}
</button>
</form>
</div>
</div> </div>
</x-guest-layout> </div>
@endsection

View File

@@ -1,63 +0,0 @@
@extends('layouts.nova')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
<div class="legacy-page">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-white">Forum</h1>
<p class="mt-1 text-sm text-zinc-300">Browse forum sections and latest activity.</p>
</div>
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
<div class="border-b border-white/10 px-4 py-3 text-sm font-semibold text-zinc-100">Forum Sections</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-sm">
<thead class="bg-zinc-800/60 text-zinc-300">
<tr>
<th class="px-4 py-3 text-left font-medium">Section</th>
<th class="px-4 py-3 text-center font-medium">Posts</th>
<th class="px-4 py-3 text-center font-medium">Topics</th>
<th class="px-4 py-3 text-right font-medium">Last Update</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10 text-zinc-100">
@forelse (($topics ?? []) as $topic)
@php
$topicId = (int) ($topic->topic_id ?? $topic->id ?? 0);
$topicTitle = $topic->topic ?? $topic->title ?? $topic->name ?? 'Untitled';
$topicSlug = Str::slug($topicTitle);
$topicUrl = $topicId > 0 ? route('legacy.forum.topic', ['topic_id' => $topicId, 'slug' => $topicSlug]) : '#';
@endphp
<tr class="hover:bg-white/5">
<td class="px-4 py-3 align-top">
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ $topicUrl }}">{{ $topicTitle }}</a>
@if (!empty($topic->discuss))
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $topic->discuss), 180) !!}</div>
@endif
</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_posts ?? 0 }}</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_subtopics ?? 0 }}</td>
<td class="px-4 py-3 text-right text-zinc-400">
@if (!empty($topic->last_update))
{{ Carbon::parse($topic->last_update)->format('d.m.Y H:i') }}
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-zinc-400">No forum sections available.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endsection

View File

@@ -1,69 +0,0 @@
@extends('layouts.nova')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
@php
$headerTitle = data_get($topic ?? null, 'topic')
?? data_get($topic ?? null, 'title')
?? data_get($thread ?? null, 'title')
?? 'Thread';
$headerDesc = data_get($topic ?? null, 'discuss')
?? data_get($thread ?? null, 'content');
@endphp
<div class="legacy-page">
<div class="mb-6">
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $headerTitle }}</h1>
@if (!empty($headerDesc))
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $headerDesc), 260) !!}</p>
@endif
</div>
<div class="space-y-4">
@forelse (($posts ?? []) as $post)
@php
$authorName = $post->uname ?? data_get($post, 'user.name') ?? 'Anonymous';
$authorId = $post->user_id ?? data_get($post, 'user.id');
$postBody = $post->message ?? $post->content ?? '';
$postedAt = $post->post_date ?? $post->created_at ?? null;
@endphp
<article class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
<header class="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div class="text-sm font-semibold text-zinc-100">{{ $authorName }}</div>
<div class="text-xs text-zinc-400">
@if (!empty($postedAt))
{{ Carbon::parse($postedAt)->format('d.m.Y H:i') }}
@endif
</div>
</header>
<div class="px-4 py-4">
<div class="prose prose-invert max-w-none text-sm leading-6">
{!! $postBody !!}
</div>
@if (!empty($authorId))
<div class="mt-4 text-xs text-zinc-500">
User ID: {{ $authorId }}
</div>
@endif
</div>
</article>
@empty
<div class="rounded-lg border border-white/10 bg-zinc-900/70 px-4 py-6 text-center text-zinc-400">
No posts yet.
</div>
@endforelse
</div>
@if (isset($posts) && method_exists($posts, 'links'))
<div class="mt-4">{{ $posts->withQueryString()->links() }}</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1 @@
@include('forum.components.category-card', ['category' => $category])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.attachment-list', ['attachments' => $attachments])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.author-badge', ['user' => $user])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.breadcrumbs', ['thread' => $thread, 'category' => $category])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.post-card', ['post' => $post, 'thread' => $thread ?? null, 'isOp' => $isOp ?? false])

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</title>
</head>
<body style="margin:0;padding:20px;background:#0b0f14;font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;color:#e6eef6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:24px auto;border-radius:12px;border:1px solid #1f2937;background:#081016;overflow:hidden;">
<tr>
<td style="padding:20px 24px;border-bottom:1px solid #111827;background:#071018;">
<h2 style="margin:0;font-size:18px;color:#fff;font-weight:600;">{{ config('app.name', 'Skinbase') }}</h2>
</td>
</tr>
<tr>
<td style="padding:24px;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0));">
<p style="margin:0 0 12px;color:#cbd5e1;">Welcome to {{ config('app.name', 'Skinbase') }} thanks for signing up.</p>
<p style="margin:0 0 18px;color:#cbd5e1;">Please verify your email to continue account setup.</p>
<div style="text-align:center;margin:20px 0;">
<a href="{{ $verificationUrl }}" style="display:inline-block;padding:12px 20px;background:#0ea5a9;color:#06121a;text-decoration:none;border-radius:8px;font-weight:600;">Verify Email</a>
</div>
<p style="margin:0 0 8px;color:#9fb0c8;font-size:13px;">This link expires in {{ $expiresInHours }} hours.</p>
<p style="margin:12px 0 0;color:#9fb0c8;font-size:13px;">Need help? Contact support: <a href="{{ $supportUrl }}" style="color:#8bd0d3;">{{ $supportUrl }}</a></p>
</td>
</tr>
<tr>
<td style="padding:12px 24px;background:#040607;border-top:1px solid #0e1113;text-align:center;color:#6b7280;font-size:12px;">© {{ date('Y') }} {{ config('app.name', 'Skinbase') }}. All rights reserved.</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,27 @@
@extends('layouts.nova')
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to thread</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Edit post</h1>
</div>
<form method="POST" action="{{ route('forum.post.update', ['post' => $post->id]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
@method('PUT')
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content', $post->content) }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Save changes</button>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,34 @@
@extends('layouts.nova')
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.category.show', ['category' => $category->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to section</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Create thread in {{ $category->name }}</h1>
</div>
<form method="POST" action="{{ route('forum.thread.store', ['category' => $category->slug]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
<div>
<label for="title" class="mb-1 block text-sm font-medium text-zinc-200">Title</label>
<input id="title" name="title" value="{{ old('title') }}" required maxlength="255" class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100" />
@error('title')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content') }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Publish thread</button>
</div>
</form>
</div>
@endsection

View File

@@ -8,11 +8,16 @@
@section('content') @section('content')
<div class="legacy-page"> <div class="legacy-page">
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a> <a href="{{ route('forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1> <h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1>
@if (!empty($topic->discuss)) @if (!empty($topic->discuss))
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p> <p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p>
@endif @endif
@if (isset($category) && auth()->check())
<div class="mt-3">
<a href="{{ route('forum.thread.create', ['category' => $category->slug]) }}" class="rounded bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">New thread</a>
</div>
@endif
</div> </div>
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70"> <div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
@@ -35,7 +40,7 @@
@endphp @endphp
<tr class="hover:bg-white/5"> <tr class="hover:bg-white/5">
<td class="px-4 py-3 align-top"> <td class="px-4 py-3 align-top">
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('legacy.forum.topic', ['topic_id' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a> <a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('forum.thread.show', ['thread' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
@if (!empty($sub->discuss)) @if (!empty($sub->discuss))
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div> <div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
@endif @endif

View File

@@ -0,0 +1,50 @@
@php
$name = data_get($category, 'name', 'Untitled');
$slug = data_get($category, 'slug');
$categoryUrl = !empty($slug) ? route('forum.category.show', ['category' => $slug]) : '#';
$threads = (int) data_get($category, 'thread_count', 0);
$posts = (int) data_get($category, 'post_count', 0);
$lastActivity = data_get($category, 'last_activity_at');
$preview = data_get($category, 'preview_image', config('forum.preview_images.default'));
@endphp
<a
href="{{ $categoryUrl }}"
aria-label="Open {{ $name }} category"
role="listitem"
class="group relative block overflow-hidden rounded-xl border border-white/5 bg-slate-900/80 shadow-xl backdrop-blur transition-all duration-300 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
>
<div class="relative aspect-[4/3] sm:aspect-[16/9]">
<img
src="{{ $preview }}"
alt="{{ $name }} preview"
loading="lazy"
decoding="async"
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute inset-x-0 bottom-0 p-4">
<div class="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<i class="fa-solid fa-comments" aria-hidden="true"></i>
</div>
<h3 class="text-lg font-semibold text-white">{{ $name }}</h3>
<p class="mt-1 text-xs text-white/60">
Last activity:
@if ($lastActivity)
<time datetime="{{ \Illuminate\Support\Carbon::parse($lastActivity)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($lastActivity)->diffForHumans() }}
</time>
@else
No activity yet
@endif
</p>
<div class="mt-3 flex items-center gap-4 text-sm text-cyan-300">
<span>{{ number_format($posts) }} posts</span>
<span>{{ number_format($threads) }} topics</span>
</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,24 @@
@extends('layouts.nova')
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-10" aria-labelledby="forum-page-title">
<div class="mx-auto max-w-7xl">
<header class="mb-8">
<h1 id="forum-page-title" class="text-3xl font-semibold text-white">Forum</h1>
<p class="mt-2 text-sm text-white/60">Browse forum sections and latest activity.</p>
</header>
@if (($categories ?? collect())->isEmpty())
<div class="rounded-xl border border-white/10 bg-slate-900/60 p-8 text-center text-white/70">
No forum categories available yet.
</div>
@else
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3" role="list" aria-label="Forum categories">
@foreach ($categories as $category)
<x-forum.category-card :category="$category" />
@endforeach
</div>
@endif
</div>
</main>
@endsection

View File

@@ -0,0 +1,84 @@
@php
$attachments = collect($attachments ?? []);
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
$toUrl = function (?string $path) use ($filesBaseUrl): string {
$cleanPath = ltrim((string) $path, '/');
return $filesBaseUrl !== '' ? ($filesBaseUrl . '/' . $cleanPath) : ('/' . $cleanPath);
};
$formatBytes = function ($bytes): string {
$size = max((int) $bytes, 0);
if ($size < 1024) {
return $size . ' B';
}
$units = ['KB', 'MB', 'GB'];
$value = $size / 1024;
$unitIndex = 0;
while ($value >= 1024 && $unitIndex < count($units) - 1) {
$value /= 1024;
$unitIndex++;
}
return number_format($value, 1) . ' ' . $units[$unitIndex];
};
@endphp
@if ($attachments->isNotEmpty())
<div class="mt-4 space-y-3 border-t border-white/10 pt-4">
<h4 class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Attachments</h4>
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
@php
$mime = (string) ($attachment->mime_type ?? '');
$isImage = str_starts_with($mime, 'image/');
$url = $toUrl($attachment->file_path ?? '');
$modalId = 'attachment-modal-' . (string) data_get($attachment, 'id', uniqid());
@endphp
<li class="rounded-lg border border-white/10 bg-slate-900/60 p-3">
@if ($isImage)
<a href="#{{ $modalId }}" class="block overflow-hidden rounded-md border border-white/10">
<img
src="{{ $url }}"
alt="Attachment preview"
loading="lazy"
decoding="async"
class="h-36 w-full object-cover"
/>
</a>
@endif
<div class="mt-2 flex items-center justify-between gap-3 text-xs">
<span class="truncate text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<span class="text-zinc-500">{{ $formatBytes($attachment->file_size ?? 0) }}</span>
</div>
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="mt-2 inline-flex text-xs font-medium text-sky-300 hover:text-sky-200">
Download
</a>
@if ($isImage)
<div id="{{ $modalId }}" class="pointer-events-none fixed inset-0 z-50 hidden bg-black/80 p-4 target:pointer-events-auto target:block" role="dialog" aria-label="Attachment preview">
<div class="mx-auto flex h-full w-full max-w-5xl items-center justify-center">
<div class="w-full overflow-hidden rounded-xl border border-white/10 bg-slate-950/95">
<div class="flex items-center justify-between border-b border-white/10 px-4 py-2">
<span class="truncate text-xs text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<a href="#" class="text-xs text-zinc-400 hover:text-zinc-200">Close</a>
</div>
<div class="max-h-[80vh] overflow-auto p-3">
<img src="{{ $url }}" alt="Attachment full preview" class="mx-auto h-auto max-h-[72vh] w-auto max-w-full object-contain" />
</div>
<div class="border-t border-white/10 px-4 py-2 text-right">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-sky-300 hover:text-sky-200">Open original</a>
</div>
</div>
</div>
</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,32 @@
@php
$user = $user ?? null;
$name = data_get($user, 'name', 'Anonymous');
$avatar = data_get($user, 'profile.avatar_url') ?? \App\Support\AvatarUrl::forUser((int) data_get($user, 'id', 0));
$role = strtolower((string) data_get($user, 'role', 'member'));
$roleLabel = match ($role) {
'admin' => 'Admin',
'moderator' => 'Moderator',
default => 'Member',
};
$roleClasses = match ($role) {
'admin' => 'bg-red-500/15 text-red-300',
'moderator' => 'bg-amber-500/15 text-amber-300',
default => 'bg-sky-500/15 text-sky-300',
};
@endphp
<div class="flex items-center gap-3">
<img
src="{{ $avatar }}"
alt="{{ $name }} avatar"
loading="lazy"
decoding="async"
class="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-zinc-100">{{ $name }}</div>
<span class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium {{ $roleClasses }}">{{ $roleLabel }}</span>
</div>
</div>

View File

@@ -0,0 +1,30 @@
@php
$thread = $thread ?? null;
$category = $category ?? null;
@endphp
<nav class="text-sm text-zinc-400" aria-label="Breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
<ol class="flex flex-wrap items-center gap-2">
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ url('/') }}" class="hover:text-zinc-200"><span itemprop="name">Home</span></a>
<meta itemprop="position" content="1">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ route('forum.index') }}" class="hover:text-zinc-200"><span itemprop="name">Forum</span></a>
<meta itemprop="position" content="2">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ isset($category) ? route('forum.category.show', ['category' => $category->slug]) : route('forum.index') }}" class="hover:text-zinc-200">
<span itemprop="name">{{ $category->name ?? 'Category' }}</span>
</a>
<meta itemprop="position" content="3">
</li>
<li aria-hidden="true">/</li>
<li class="text-zinc-200" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<span itemprop="name">{{ $thread->title ?? 'Thread' }}</span>
<meta itemprop="position" content="4">
</li>
</ol>
</nav>

View File

@@ -0,0 +1,71 @@
@php
$post = $post ?? null;
$thread = $thread ?? null;
$isOp = (bool) ($isOp ?? false);
$author = data_get($post, 'user');
$postedAt = data_get($post, 'created_at');
$editedAt = data_get($post, 'edited_at');
$content = (string) data_get($post, 'content', '');
$rendered = \App\Support\ForumPostContent::render($content);
@endphp
<article class="overflow-hidden rounded-xl border border-white/5 bg-slate-900/70 backdrop-blur" id="post-{{ data_get($post, 'id') }}">
<header class="border-b border-white/10 px-4 py-3 sm:px-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<x-forum.thread.author-badge :user="$author" />
<div class="text-xs text-zinc-400">
@if ($postedAt)
<time datetime="{{ \Illuminate\Support\Carbon::parse($postedAt)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($postedAt)->format('d.m.Y H:i') }}
</time>
@endif
@if ($isOp)
<span class="ml-2 rounded-full bg-cyan-500/15 px-2 py-0.5 text-[11px] font-medium text-cyan-300">OP</span>
@endif
</div>
</div>
</header>
<div class="px-4 py-4 sm:px-5">
<div class="prose prose-invert max-w-none text-sm leading-6 prose-pre:overflow-x-auto">
{!! $rendered !!}
</div>
@if (data_get($post, 'is_edited') && $editedAt)
<p class="mt-3 text-xs text-zinc-500">
Edited <time datetime="{{ \Illuminate\Support\Carbon::parse($editedAt)->toIso8601String() }}">{{ \Illuminate\Support\Carbon::parse($editedAt)->diffForHumans() }}</time>
</p>
@endif
<x-forum.thread.attachment-list :attachments="data_get($post, 'attachments', [])" />
</div>
<footer class="flex flex-wrap items-center gap-3 border-t border-white/10 px-4 py-3 text-xs text-zinc-400 sm:px-5">
<button type="button" disabled aria-disabled="true" title="Like coming soon" class="cursor-not-allowed rounded border border-white/10 px-2 py-0.5 text-zinc-500">Like</button>
@if (!empty(data_get($thread, 'id')))
<a href="{{ route('forum.thread.show', ['thread' => data_get($thread, 'id'), 'slug' => data_get($thread, 'slug'), 'quote' => data_get($post, 'id')]) }}#reply-content" class="hover:text-zinc-200">Quote</a>
@else
<a href="#post-{{ data_get($post, 'id') }}" class="hover:text-zinc-200">Quote</a>
@endif
@auth
@if ((int) data_get($post, 'user_id') !== (int) auth()->id())
<form method="POST" action="{{ route('forum.post.report', ['post' => data_get($post, 'id')]) }}" class="inline">
@csrf
<button type="submit" class="hover:text-zinc-200">Report</button>
</form>
@endif
@else
<a href="{{ route('login') }}" class="hover:text-zinc-200">Report</a>
@endauth
@auth
@if ((int) data_get($post, 'user_id') === (int) auth()->id() || Gate::allows('moderate-forum'))
<a href="{{ route('forum.post.edit', ['post' => data_get($post, 'id')]) }}" class="hover:text-zinc-200">Edit</a>
@endif
@endauth
@can('moderate-forum')
<span class="ml-auto text-amber-300">Moderation tools available</span>
@endcan
</footer>
</article>

View File

@@ -0,0 +1,124 @@
@extends('layouts.nova')
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-8" aria-labelledby="thread-title">
<div class="mx-auto max-w-5xl space-y-5">
<x-forum.thread.breadcrumbs :thread="$thread" :category="$category" />
@if (session('status'))
<div class="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
{{ session('status') }}
</div>
@endif
<section class="rounded-xl border border-white/5 bg-slate-900/70 p-5 backdrop-blur">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 id="thread-title" class="text-2xl font-semibold text-white">{{ $thread->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span>By {{ $author->name ?? 'Unknown' }}</span>
<span aria-hidden="true"></span>
<time datetime="{{ optional($thread->created_at)?->toIso8601String() }}">{{ optional($thread->created_at)?->format('d.m.Y H:i') }}</time>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">{{ number_format((int) ($thread->views ?? 0)) }} views</span>
<span class="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">{{ number_format((int) ($reply_count ?? 0)) }} replies</span>
@if ($thread->is_pinned)
<span class="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
@endif
@if ($thread->is_locked)
<span class="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
@endif
</div>
</div>
@can('moderate-forum')
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-xs">
@if ($thread->is_locked)
<form method="POST" action="{{ route('forum.thread.unlock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Unlock thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.lock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Lock thread</button>
</form>
@endif
@if ($thread->is_pinned)
<form method="POST" action="{{ route('forum.thread.unpin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Unpin thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.pin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Pin thread</button>
</form>
@endif
</div>
@endcan
</section>
@if (isset($opPost) && $opPost)
<x-forum.thread.post-card :post="$opPost" :thread="$thread" :is-op="true" />
@endif
<section class="space-y-4" aria-label="Replies">
@forelse ($posts as $post)
<x-forum.thread.post-card :post="$post" :thread="$thread" />
@empty
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-6 text-center text-zinc-400">
No replies yet.
</div>
@endforelse
</section>
@if (method_exists($posts, 'links'))
<div class="sticky bottom-3 z-10 rounded-xl border border-white/10 bg-slate-900/80 p-2 backdrop-blur supports-[backdrop-filter]:bg-slate-900/70">
{{ $posts->withQueryString()->links() }}
</div>
@endif
@auth
@if (!$thread->is_locked)
<form method="POST" action="{{ route('forum.thread.reply', ['thread' => $thread->id]) }}" class="space-y-3 rounded-xl border border-white/5 bg-slate-900/70 p-4 backdrop-blur">
@csrf
<div class="flex items-center justify-between">
<label for="reply-content" class="text-sm font-medium text-zinc-200">Reply</label>
<span class="text-xs text-zinc-500">Minimum 2 characters</span>
</div>
<div class="rounded-lg border border-white/10 bg-slate-950 p-2">
<div class="mb-2 flex items-center gap-2 text-xs">
<button type="button" class="rounded bg-slate-800 px-2 py-1 text-zinc-200" aria-pressed="true">Write</button>
<span class="rounded bg-slate-900 px-2 py-1 text-zinc-500">Preview (coming soon)</span>
</div>
<textarea id="reply-content" name="content" rows="6" required minlength="2" maxlength="10000" class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-cyan-400 focus:outline-none focus:ring-1 focus:ring-cyan-400">{{ $reply_prefill ?? old('content') }}</textarea>
</div>
@error('content')
<p class="text-xs text-red-400">{{ $message }}</p>
@enderror
@if (!empty($quoted_post))
<p class="text-xs text-cyan-300">Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.</p>
@endif
<div class="flex items-center justify-between">
<p class="text-xs text-zinc-500">Markdown/BBCode + attachments will be enabled in next pass</p>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">Post reply</button>
</div>
</form>
@else
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
This thread is locked. Replies are disabled.
</div>
@endif
@else
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-4 text-sm text-zinc-300">
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to post a reply.
</div>
@endauth
</div>
</main>
@endsection

View File

@@ -10,18 +10,25 @@
<div class="pt-0"> <div class="pt-0">
<div class="mx-auto w-full"> <div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]"> <div class="relative flex min-h-[calc(100vh-64px)]">
<button
id="sidebar-toggle"
type="button"
class="hidden md:inline-flex items-center justify-center h-10 w-10 rounded-lg border border-white/10 bg-white/5 text-white/90 hover:bg-white/10 absolute top-3 z-20"
aria-controls="sidebar"
aria-expanded="true"
aria-label="Toggle sidebar"
style="left:16px;"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <div class="mt-2 text-sm text-neutral-400">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div> <div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2"> <ul class="space-y-2">
@foreach($mainCategories as $main) @foreach($mainCategories as $main)
@@ -32,7 +39,7 @@
</ul> </ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div> <div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2"> <ul class="space-y-2 pr-2">
@forelse($subcategories as $sub) @forelse($subcategories as $sub)
@php @php
$subName = $sub->category_name ?? $sub->name ?? null; $subName = $sub->category_name ?? $sub->name ?? null;
@@ -128,9 +135,13 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
} }
@media (min-width: 2600px) { /* Larger desktop screens: 5 columns */
@media (min-width: 1600px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
} }
@media (min-width: 2600px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; } [data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* Keep pagination visible when JS enhances the gallery so users /* Keep pagination visible when JS enhances the gallery so users
have a clear navigation control (numeric links for length-aware have a clear navigation control (numeric links for length-aware
@@ -184,4 +195,64 @@
@push('scripts') @push('scripts')
<script src="/js/legacy-gallery-init.js" defer></script> <script src="/js/legacy-gallery-init.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('sidebar-toggle');
var sidebar = document.getElementById('sidebar');
if (!toggle || !sidebar) return;
var collapsed = false;
try {
collapsed = window.localStorage.getItem('gallery.sidebar.collapsed') === '1';
} catch (e) {
collapsed = false;
}
function applySidebarState() {
if (collapsed) {
sidebar.classList.add('md:hidden');
toggle.setAttribute('aria-expanded', 'false');
} else {
sidebar.classList.remove('md:hidden');
toggle.setAttribute('aria-expanded', 'true');
}
positionToggle();
}
toggle.addEventListener('click', function () {
collapsed = !collapsed;
applySidebarState();
try {
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
} catch (e) {
// no-op
}
});
function positionToggle() {
if (!toggle || !sidebar) return;
// when sidebar is visible, position toggle just outside its right edge
if (!collapsed) {
var rect = sidebar.getBoundingClientRect();
if (rect && rect.right) {
toggle.style.left = (rect.right + 8) + 'px';
toggle.style.transform = '';
} else {
// fallback to sidebar width (18rem)
toggle.style.left = 'calc(18rem + 8px)';
}
} else {
// when collapsed, position toggle near page left edge
toggle.style.left = '16px';
toggle.style.transform = '';
}
}
window.addEventListener('resize', function () { positionToggle(); });
applySidebarState();
// ensure initial position set
positionToggle();
});
</script>
@endpush @endpush

View File

@@ -127,8 +127,12 @@
<!-- User dropdown --> <!-- User dropdown -->
<div class="relative"> <div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user"> <button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
@php
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
@endphp
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10" <img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? (Auth::id() ?? 0)), $avatarHash ?? null, 64) }}" src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="{{ $displayName ?? 'User' }}" /> alt="{{ $displayName ?? 'User' }}" />
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span> <span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -200,6 +204,13 @@
</a> </a>
<div class="px-4 dd-section">System</div> <div class="px-4 dd-section">System</div>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-shield text-sb-muted"></i></span>
Username Moderation
</a>
@endif
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-sign-out text-sb-muted"></i></span> class="fa-solid fa-sign-out text-sb-muted"></i></span>
@@ -211,7 +222,7 @@
@else @else
<!-- Guest: show simple Join / Sign in links --> <!-- Guest: show simple Join / Sign in links -->
<div class="hidden md:flex items-center gap-3"> <div class="hidden md:flex items-center gap-3">
<a href="/signup" <a href="/register"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a> class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
<a href="/login" <a href="/login"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a> class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
@@ -235,6 +246,11 @@
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a> <a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a> <a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a> <a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
@auth
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
@endif
@endauth
<a class="block py-2" href="/settings">Settings</a> <a class="block py-2" href="/settings">Settings</a>
</div> </div>
</div> </div>

View File

@@ -1,51 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Forum</h1>
<p>Latest threads</p>
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Forum Threads</strong></div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Thread</th>
<th class="text-center">Posts</th>
<th class="text-center">Topics</th>
<th class="text-right">Last Update</th>
</tr>
</thead>
<tbody>
@forelse ($topics as $topic)
<tr>
<td>
<h4 style="margin:0;">
<a href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">{{ $topic->topic }}</a>
</h4>
<div class="text-muted">{!! $topic->discuss !!}</div>
</td>
<td class="text-center">{{ $topic->num_posts ?? 0 }}</td>
<td class="text-center">{{ $topic->num_subtopics ?? 0 }}</td>
<td class="text-right">{{ $topic->last_update ? Carbon::parse($topic->last_update)->format('H:i @ d.m') : '' }}</td>
</tr>
@empty
<tr><td colspan="4">No threads available.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,51 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
<h1 class="page-header">{{ $topic->topic }}</h1>
@if (!empty($topic->discuss))
<p>{!! $topic->discuss !!}</p>
@endif
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Posts</strong></div>
<div class="panel-body">
@forelse ($posts as $post)
<div class="panel panel-default effect2" style="overflow:hidden;">
<div class="panel-heading clearfix">
<div class="pull-right text-muted">{{ $post->post_date ? Carbon::parse($post->post_date)->format('d.m.Y H:i') : '' }}</div>
<strong>{{ $post->uname ?? 'Anonymous' }}</strong>
</div>
<div class="panel-body" style="display:flex; gap:12px;">
<div style="min-width:52px;">
@if (!empty($post->user_id) && !empty($post->icon))
<img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
@else
<div class="img-thumbnail" style="width:50px;height:50px;"></div>
@endif
</div>
<div style="flex:1;">
{!! $post->message !!}
</div>
</div>
</div>
@empty
<p>No posts yet.</p>
@endforelse
<div class="paginationMenu text-center">
{{ $posts->withQueryString()->links('pagination::bootstrap-4') }}
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,56 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
<h1 class="page-header">{{ $topic->topic }}</h1>
@if (!empty($topic->discuss))
<p>{!! $topic->discuss !!}</p>
@endif
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Topics</strong></div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Topic</th>
<th class="text-center">Opened By</th>
<th class="text-right">Posted</th>
</tr>
</thead>
<tbody>
@forelse ($subtopics as $sub)
<tr>
<td>{{ $sub->topic_id }}</td>
<td>
<a href="/forum/{{ $sub->topic_id }}/{{ Str::slug($sub->topic ?? '') }}">{{ $sub->topic }}</a>
<div class="text-muted small">{!! Str::limit(strip_tags($sub->discuss ?? ''), 160) !!}</div>
</td>
<td class="text-center">{{ $sub->uname ?? 'Unknown' }}</td>
<td class="text-right">{{ $sub->last_update ? Carbon::parse($sub->last_update)->format('d.m.Y H:i') : '' }}</td>
</tr>
@empty
<tr><td colspan="4">No topics yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="paginationMenu text-center">
{{ $subtopics->withQueryString()->links('pagination::bootstrap-4') }}
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,6 +1,10 @@
{{-- News and forum columns --}} {{-- News and forum columns --}}
<div class="row news-row"> <div class="row news-row">
<div class="col-sm-6"> <div class="col-sm-6">
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@forelse ($forumNews as $item) @forelse ($forumNews as $item)
<div class="panel panel-skinbase effect2"> <div class="panel panel-skinbase effect2">
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div> <div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
@@ -10,7 +14,7 @@
</div> </div>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!} {!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
<br> <br>
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a> <a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
</div> </div>
</div> </div>
@empty @empty
@@ -67,7 +71,7 @@
<div class="panel-body"> <div class="panel-body">
<div class="list-group effect2"> <div class="list-group effect2">
@forelse ($latestForumActivity as $topic) @forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}"> <a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span> {{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
</a> </a>
@empty @empty

View File

@@ -2,6 +2,19 @@
@section('content') @section('content')
@php
$birthDay = $birthDay ?? null;
$birthMonth = $birthMonth ?? null;
$birthYear = $birthYear ?? null;
$avatarUserId = (int) ($user->id ?? auth()->id() ?? 0);
$avatarHash = $user->icon
?? optional(auth()->user())->profile->avatar_hash
?? null;
$currentAvatarUrl = !empty($avatarHash)
? \App\Support\AvatarUrl::forUser($avatarUserId, $avatarHash, 128)
: \App\Support\AvatarUrl::default();
@endphp
<div class="min-h-screen bg-deep text-white py-12"> <div class="min-h-screen bg-deep text-white py-12">
<!-- Container --> <!-- Container -->
@@ -146,7 +159,30 @@
<!-- Avatar --> <!-- Avatar -->
<div> <div>
<label class="form-label">Avatar</label> <label class="form-label">Avatar</label>
<input type="file" name="avatar" class="form-file"> <div class="rounded-xl border border-sb-line bg-black/10 p-4">
<div class="flex items-center gap-4">
<img
id="avatarPreviewImage"
src="{{ $currentAvatarUrl }}"
alt="{{ $user->name ?? $user->username ?? 'Avatar' }}"
class="w-24 h-24 rounded-full object-cover ring-1 ring-white/10"
loading="lazy"
decoding="async"
>
<div class="min-w-0 flex-1">
<button
type="button"
id="avatarDropzone"
class="w-full rounded-lg border border-dashed border-sb-line px-4 py-4 text-left hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-sb-blue/50"
>
<div class="text-sm text-white/90">Drag & drop a new avatar here</div>
<div class="text-xs text-soft mt-1">or click to browse (JPG, PNG, WEBP)</div>
<div id="avatarFileName" class="text-xs text-sb-muted mt-2 truncate">No file selected</div>
</button>
<input id="avatarInput" type="file" name="avatar" class="sr-only" accept="image/jpeg,image/png,image/webp">
</div>
</div>
</div>
</div> </div>
<!-- Emoticon --> <!-- Emoticon -->
@@ -245,3 +281,65 @@
</div> </div>
@endsection @endsection
@push('scripts')
<script>
(() => {
const input = document.getElementById('avatarInput');
const dropzone = document.getElementById('avatarDropzone');
const preview = document.getElementById('avatarPreviewImage');
const fileName = document.getElementById('avatarFileName');
if (!input || !dropzone || !preview || !fileName) {
return;
}
const updatePreview = (file) => {
if (!file || !file.type || !file.type.startsWith('image/')) {
return;
}
const objectUrl = URL.createObjectURL(file);
preview.src = objectUrl;
fileName.textContent = file.name;
preview.onload = () => URL.revokeObjectURL(objectUrl);
};
dropzone.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files && input.files[0] ? input.files[0] : null;
if (file) {
updatePreview(file);
}
});
['dragenter', 'dragover'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
dropzone.classList.add('ring-2', 'ring-sb-blue/50');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
dropzone.classList.remove('ring-2', 'ring-sb-blue/50');
});
});
dropzone.addEventListener('drop', (event) => {
const file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null;
if (!file) {
return;
}
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
updatePreview(file);
});
})();
</script>
@endpush

View File

@@ -28,6 +28,24 @@
data-initial-src="{{ $avatarInitialSrc }}" data-initial-src="{{ $avatarInitialSrc }}"
></div> ></div>
<div>
<x-input-label for="username" :value="__('Username')" />
<x-text-input
id="username"
name="username"
type="text"
class="mt-1 block w-full"
:value="old('username', $user->username)"
required
autocomplete="username"
data-username-field="true"
data-availability-url="{{ route('api.username.availability') }}"
data-availability-target="profile-username-availability"
/>
<p id="profile-username-availability" class="mt-1 text-xs text-gray-500"></p>
<x-input-error class="mt-2" :messages="$errors->get('username')" />
</div>
<div> <div>
<x-input-label for="name" :value="__('Name')" /> <x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" /> <x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />

View File

@@ -1,4 +1,9 @@
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}} {{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
<div class="row news-row"> <div class="row news-row">
<div class="col-sm-6"> <div class="col-sm-6">
@forelse ($forumNews as $item) @forelse ($forumNews as $item)
@@ -10,7 +15,7 @@
</div> </div>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!} {!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
<br> <br>
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a> <a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
</div> </div>
</div> </div>
@empty @empty
@@ -67,7 +72,7 @@
<div class="panel-body"> <div class="panel-body">
<div class="list-group effect2"> <div class="list-group effect2">
@forelse ($latestForumActivity as $topic) @forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}"> <a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span> {{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
</a> </a>
@empty @empty

View File

@@ -13,7 +13,14 @@
} }
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork')); $title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
$author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase')); $author = trim((string) (
$art->uname
?? $art->author_name
?? $art->author
?? ($art->user->name ?? null)
?? ($art->user->username ?? null)
?? 'Skinbase'
));
$category = trim((string) ($art->category_name ?? $art->category ?? 'General')); $category = trim((string) ($art->category_name ?? $art->category ?? 'General'));
$license = trim((string) ($art->license ?? 'Standard')); $license = trim((string) ($art->license ?? 'Standard'));
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : ''))); $resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));

View File

@@ -38,12 +38,16 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('feed'); ->name('feed');
}); });
Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.')->group(function () { Route::middleware(['web', 'normalize.username', 'throttle:30,1'])
->get('username/availability', \App\Http\Controllers\Api\UsernameAvailabilityController::class)
->name('api.username.availability');
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () {
Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store']) Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store'])
->name('store'); ->name('store');
}); });
Route::middleware(['web', 'auth'])->prefix('uploads')->name('api.uploads.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () {
Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init']) Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init'])
->middleware('throttle:uploads-init') ->middleware('throttle:uploads-init')
->name('init'); ->name('init');
@@ -102,6 +106,19 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')-
->name('feed-performance'); ->name('feed-performance');
}); });
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () {
Route::get('pending', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'pending'])
->name('pending');
Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'approve'])
->whereNumber('id')
->name('approve');
Route::post('{id}/reject', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'reject'])
->whereNumber('id')
->name('reject');
});
Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtworkAnalyticsController::class, 'store']) Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtworkAnalyticsController::class, 'store'])
->middleware('throttle:uploads-status') ->middleware('throttle:uploads-status')
->name('api.analytics.similar-artworks.store'); ->name('api.analytics.similar-artworks.store');
@@ -110,19 +127,19 @@ Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controller
->middleware('throttle:uploads-status') ->middleware('throttle:uploads-status')
->name('api.analytics.feed.store'); ->name('api.analytics.feed.store');
Route::middleware(['web', 'auth'])->prefix('discovery')->name('api.discovery.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () {
Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store']) Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store'])
->middleware('throttle:uploads-status') ->middleware('throttle:uploads-status')
->name('events.store'); ->name('events.store');
}); });
// Tag system (auth-protected; 404-only ownership checks handled in requests/controllers) // Tag system (auth-protected; 404-only ownership checks handled in requests/controllers)
Route::middleware(['web', 'auth'])->prefix('tags')->name('api.tags.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('tags')->name('api.tags.')->group(function () {
Route::get('search', [\App\Http\Controllers\Api\TagController::class, 'search'])->name('search'); Route::get('search', [\App\Http\Controllers\Api\TagController::class, 'search'])->name('search');
Route::get('popular', [\App\Http\Controllers\Api\TagController::class, 'popular'])->name('popular'); Route::get('popular', [\App\Http\Controllers\Api\TagController::class, 'popular'])->name('popular');
}); });
Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.tags.')->group(function () { Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.tags.')->group(function () {
Route::get('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'index'])->whereNumber('id')->name('index'); Route::get('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'index'])->whereNumber('id')->name('index');
Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store'); Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store');
Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update'); Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update');

View File

@@ -8,14 +8,28 @@ use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\RegistrationVerificationController;
use App\Http\Controllers\Auth\SetupPasswordController;
use App\Http\Controllers\Auth\SetupUsernameController;
use App\Http\Controllers\Auth\VerifyEmailController; use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () { Route::middleware(['guest', 'normalize.username'])->group(function () {
Route::get('register', [RegisteredUserController::class, 'create']) Route::get('register', [RegisteredUserController::class, 'create'])
->name('register'); ->name('register');
Route::post('register', [RegisteredUserController::class, 'store']); Route::get('register/notice', [RegisteredUserController::class, 'notice'])
->name('register.notice');
Route::post('register', [RegisteredUserController::class, 'store'])
->middleware('throttle:register');
Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification'])
->middleware('throttle:register')
->name('register.resend');
Route::get('verify/{token}', RegistrationVerificationController::class)
->name('registration.verify');
Route::get('login', [AuthenticatedSessionController::class, 'create']) Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login'); ->name('login');
@@ -36,6 +50,18 @@ Route::middleware('guest')->group(function () {
}); });
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('setup/password', [SetupPasswordController::class, 'create'])
->name('setup.password.create');
Route::post('setup/password', [SetupPasswordController::class, 'store'])
->name('setup.password.store');
Route::get('setup/username', [SetupUsernameController::class, 'create'])
->name('setup.username.create');
Route::post('setup/username', [SetupUsernameController::class, 'store'])
->name('setup.username.store');
Route::get('verify-email', EmailVerificationPromptController::class) Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice'); ->name('verification.notice');

Some files were not shown because too many files have changed in this diff Show More