From 795c7a835f829db918797bc29e25aa269cdd987d Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 21 Feb 2026 07:37:08 +0100 Subject: [PATCH] Auth: convert auth views and verification email to Nova layout --- app/Console/Commands/AvatarsMigrate.php | 368 +++++++++++++----- .../Commands/EnforceUsernamePolicy.php | 96 +++++ app/Console/Commands/ForumMigrateOld.php | 286 ++++++++++++-- app/Console/Commands/ImportLegacyUsers.php | 45 ++- app/Console/Kernel.php | 1 + .../Api/Admin/UsernameApprovalController.php | 149 +++++++ app/Http/Controllers/Api/UploadController.php | 1 + .../Api/UsernameAvailabilityController.php | 44 +++ .../Auth/RegisteredUserController.php | 156 +++++++- .../RegistrationVerificationController.php | 53 +++ .../Auth/SetupPasswordController.php | 45 +++ .../Auth/SetupUsernameController.php | 94 +++++ .../Controllers/Community/ForumController.php | 101 ----- .../Controllers/Forum/ForumController.php | 348 +++++++++++++++++ .../Controllers/Legacy/ForumController.php | 38 -- app/Http/Controllers/LegacyController.php | 180 ++------- .../Controllers/User/ProfileController.php | 136 +++++++ app/Http/Controllers/User/UserController.php | 2 +- app/Http/Controllers/Web/HomeController.php | 55 ++- .../Middleware/EnsureOnboardingComplete.php | 36 ++ app/Http/Middleware/NormalizeUsername.php | 28 ++ app/Http/Requests/ProfileUpdateRequest.php | 12 +- app/Http/Requests/UsernameRequest.php | 73 ++++ app/Mail/RegistrationVerificationMail.php | 62 +++ app/Models/ForumCategory.php | 73 +++- app/Models/ForumPost.php | 33 +- app/Models/ForumPostReport.php | 40 ++ app/Models/ForumThread.php | 34 +- app/Models/User.php | 6 + app/Providers/AppServiceProvider.php | 36 ++ app/Services/ArtworkService.php | 39 +- app/Services/LegacyService.php | 180 ++------- app/Services/Security/RecaptchaVerifier.php | 51 +++ app/Services/UsernameApprovalService.php | 48 +++ app/Support/AvatarUrl.php | 8 +- app/Support/ForumPostContent.php | 36 ++ app/Support/UsernamePolicy.php | 128 ++++++ avatar_patch.diff | 140 +++---- bootstrap/app.php | 2 + config/antispam.php | 9 + config/cdn.php | 2 +- config/database.php | 12 +- config/forum.php | 10 + config/services.php | 8 + config/usernames.php | 44 +++ database/factories/UserFactory.php | 10 + ...220000_create_forum_post_reports_table.php | 31 ++ ...enforce_username_policy_on_users_table.php | 28 ++ ...enforce_username_policy_on_users_table.php | 110 ++++++ ..._username_history_and_redirects_tables.php | 37 ++ ...reate_username_approval_requests_table.php | 33 ++ ..._191000_add_registration_phase1_schema.php | 51 +++ ...ce_username_lowercase_check_constraint.php | 53 +++ docs/avatar-cdn-config-notes.md | 4 +- public/legacy/assets/js/application.js | 47 ++- resources/js/Pages/Admin/UsernameQueue.jsx | 6 + resources/js/app.js | 1 + .../components/admin/AdminUsernameQueue.jsx | 92 +++++ resources/js/username-availability.js | 62 +++ resources/views/artworks/show.blade.php | 2 +- .../views/auth/confirm-password.blade.php | 56 +-- .../views/auth/forgot-password.blade.php | 51 +-- resources/views/auth/login.blade.php | 80 ++-- .../partials/onboarding-progress.blade.php | 27 ++ .../views/auth/register-notice.blade.php | 86 ++++ resources/views/auth/register.blade.php | 79 ++-- resources/views/auth/reset-password.blade.php | 73 ++-- resources/views/auth/setup-password.blade.php | 44 +++ resources/views/auth/setup-username.blade.php | 52 +++ resources/views/auth/verify-email.blade.php | 55 +-- .../views/community/forum/index.blade.php | 63 --- .../views/community/forum/posts.blade.php | 69 ---- .../components/forum/category-card.blade.php | 1 + .../forum/thread/attachment-list.blade.php | 1 + .../forum/thread/author-badge.blade.php | 1 + .../forum/thread/breadcrumbs.blade.php | 1 + .../forum/thread/post-card.blade.php | 1 + .../registration-verification.blade.php | 39 ++ .../views/forum/community/edit-post.blade.php | 27 ++ .../forum/community/new-thread.blade.php | 34 ++ .../forum => forum/community}/topic.blade.php | 9 +- .../forum/components/category-card.blade.php | 50 +++ resources/views/forum/index.blade.php | 24 ++ .../components/attachment-list.blade.php | 84 ++++ .../thread/components/author-badge.blade.php | 32 ++ .../thread/components/breadcrumbs.blade.php | 30 ++ .../thread/components/post-card.blade.php | 71 ++++ resources/views/forum/thread/show.blade.php | 124 ++++++ resources/views/gallery/index.blade.php | 93 ++++- .../views/layouts/nova/toolbar.blade.php | 20 +- resources/views/legacy/forum/index.blade.php | 51 --- resources/views/legacy/forum/posts.blade.php | 51 --- resources/views/legacy/forum/topic.blade.php | 56 --- resources/views/legacy/home/news.blade.php | 8 +- resources/views/legacy/user.blade.php | 100 ++++- .../update-profile-information-form.blade.php | 18 + resources/views/web/home/news.blade.php | 9 +- .../web/partials/_artwork_card.blade.php | 9 +- routes/api.php | 27 +- routes/auth.php | 30 +- routes/web.php | 61 ++- scripts/check_forum_counts.php | 69 ---- test-results/.last-run.json | 4 - .../Admin/UsernameApprovalModerationTest.php | 115 ++++++ .../Auth/EnsureOnboardingCompleteTest.php | 54 +++ tests/Feature/Auth/OnboardingUxTest.php | 53 +++ .../Feature/Auth/RegistrationAntiSpamTest.php | 74 ++++ .../Auth/RegistrationFlowChecklistTest.php | 125 ++++++ .../Auth/RegistrationNoticeResendTest.php | 60 +++ tests/Feature/Auth/RegistrationTest.php | 30 +- .../RegistrationTokenVerificationTest.php | 74 ++++ .../Auth/RegistrationVerificationMailTest.php | 43 ++ tests/Feature/Auth/SetupPasswordTest.php | 62 +++ tests/Feature/Auth/SetupUsernameTest.php | 77 ++++ .../Feature/Auth/UsernameAvailabilityTest.php | 38 ++ tests/Feature/Auth/UsernamePolicyTest.php | 83 ++++ tests/Feature/AutoTagArtworkJobTest.php | 3 + 117 files changed, 5385 insertions(+), 1291 deletions(-) create mode 100644 app/Console/Commands/EnforceUsernamePolicy.php create mode 100644 app/Http/Controllers/Api/Admin/UsernameApprovalController.php create mode 100644 app/Http/Controllers/Api/UsernameAvailabilityController.php create mode 100644 app/Http/Controllers/Auth/RegistrationVerificationController.php create mode 100644 app/Http/Controllers/Auth/SetupPasswordController.php create mode 100644 app/Http/Controllers/Auth/SetupUsernameController.php delete mode 100644 app/Http/Controllers/Community/ForumController.php create mode 100644 app/Http/Controllers/Forum/ForumController.php delete mode 100644 app/Http/Controllers/Legacy/ForumController.php create mode 100644 app/Http/Middleware/EnsureOnboardingComplete.php create mode 100644 app/Http/Middleware/NormalizeUsername.php create mode 100644 app/Http/Requests/UsernameRequest.php create mode 100644 app/Mail/RegistrationVerificationMail.php create mode 100644 app/Models/ForumPostReport.php create mode 100644 app/Services/Security/RecaptchaVerifier.php create mode 100644 app/Services/UsernameApprovalService.php create mode 100644 app/Support/ForumPostContent.php create mode 100644 app/Support/UsernamePolicy.php create mode 100644 config/antispam.php create mode 100644 config/forum.php create mode 100644 config/usernames.php create mode 100644 database/migrations/2026_02_19_220000_create_forum_post_reports_table.php create mode 100644 database/migrations/2026_02_20_173242_enforce_username_policy_on_users_table.php create mode 100644 database/migrations/2026_02_20_180000_enforce_username_policy_on_users_table.php create mode 100644 database/migrations/2026_02_20_180100_create_username_history_and_redirects_tables.php create mode 100644 database/migrations/2026_02_20_181000_create_username_approval_requests_table.php create mode 100644 database/migrations/2026_02_20_191000_add_registration_phase1_schema.php create mode 100644 database/migrations/2026_02_20_191100_enforce_username_lowercase_check_constraint.php create mode 100644 resources/js/Pages/Admin/UsernameQueue.jsx create mode 100644 resources/js/components/admin/AdminUsernameQueue.jsx create mode 100644 resources/js/username-availability.js create mode 100644 resources/views/auth/partials/onboarding-progress.blade.php create mode 100644 resources/views/auth/register-notice.blade.php create mode 100644 resources/views/auth/setup-password.blade.php create mode 100644 resources/views/auth/setup-username.blade.php delete mode 100644 resources/views/community/forum/index.blade.php delete mode 100644 resources/views/community/forum/posts.blade.php create mode 100644 resources/views/components/forum/category-card.blade.php create mode 100644 resources/views/components/forum/thread/attachment-list.blade.php create mode 100644 resources/views/components/forum/thread/author-badge.blade.php create mode 100644 resources/views/components/forum/thread/breadcrumbs.blade.php create mode 100644 resources/views/components/forum/thread/post-card.blade.php create mode 100644 resources/views/emails/registration-verification.blade.php create mode 100644 resources/views/forum/community/edit-post.blade.php create mode 100644 resources/views/forum/community/new-thread.blade.php rename resources/views/{community/forum => forum/community}/topic.blade.php (85%) create mode 100644 resources/views/forum/components/category-card.blade.php create mode 100644 resources/views/forum/index.blade.php create mode 100644 resources/views/forum/thread/components/attachment-list.blade.php create mode 100644 resources/views/forum/thread/components/author-badge.blade.php create mode 100644 resources/views/forum/thread/components/breadcrumbs.blade.php create mode 100644 resources/views/forum/thread/components/post-card.blade.php create mode 100644 resources/views/forum/thread/show.blade.php delete mode 100644 resources/views/legacy/forum/index.blade.php delete mode 100644 resources/views/legacy/forum/posts.blade.php delete mode 100644 resources/views/legacy/forum/topic.blade.php delete mode 100644 scripts/check_forum_counts.php delete mode 100644 test-results/.last-run.json create mode 100644 tests/Feature/Admin/UsernameApprovalModerationTest.php create mode 100644 tests/Feature/Auth/EnsureOnboardingCompleteTest.php create mode 100644 tests/Feature/Auth/OnboardingUxTest.php create mode 100644 tests/Feature/Auth/RegistrationAntiSpamTest.php create mode 100644 tests/Feature/Auth/RegistrationFlowChecklistTest.php create mode 100644 tests/Feature/Auth/RegistrationNoticeResendTest.php create mode 100644 tests/Feature/Auth/RegistrationTokenVerificationTest.php create mode 100644 tests/Feature/Auth/RegistrationVerificationMailTest.php create mode 100644 tests/Feature/Auth/SetupPasswordTest.php create mode 100644 tests/Feature/Auth/SetupUsernameTest.php create mode 100644 tests/Feature/Auth/UsernameAvailabilityTest.php create mode 100644 tests/Feature/Auth/UsernamePolicyTest.php diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php index 6267e3cd..576e200d 100644 --- a/app/Console/Commands/AvatarsMigrate.php +++ b/app/Console/Commands/AvatarsMigrate.php @@ -2,124 +2,308 @@ namespace App\Console\Commands; -use App\Services\AvatarService; 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 { - 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) - { - parent::__construct(); - } + /** + * Allowed MIME types for source images. + * + * @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 { - $force = (bool) $this->option('force'); - $limit = max(0, (int) $this->option('limit')); + $dry = $this->option('dry-run'); + $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') - ->leftJoin('users as u', 'u.id', '=', 'p.user_id') - ->select([ - 'p.user_id', - 'p.avatar_hash', - 'p.avatar_legacy', - 'u.icon as user_icon', - ]) - ->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash')) - ->where(function ($query) { - $query->whereNotNull('p.avatar_legacy') - ->orWhereNotNull('u.icon'); - }) - ->orderBy('p.user_id') - ->when($limit > 0, fn ($query) => $query->limit($limit)) - ->get(); - - if ($rows->isEmpty()) { - $this->info('No avatars require migration.'); - - return self::SUCCESS; + // Detect processing backend: Intervention preferred, GD fallback + $useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic'); + if ($useIntervention) { + Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']); } - $migrated = 0; - $skipped = 0; - $failed = 0; + $bar = null; - foreach ($rows as $row) { - $userId = (int) $row->user_id; - $legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon); + User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) { + foreach ($users as $user) { + /** @var UserProfile|null $profile */ + $profile = $user->profile; - if ($legacyName === null) { - $skipped++; - continue; - } - - $path = $this->locateLegacyAvatarPath($userId, $legacyName); - if ($path === null) { - $failed++; - $this->warn("User {$userId}: legacy avatar not found ({$legacyName})"); - continue; - } - - try { - $hash = $this->service->storeFromLegacyFile($userId, $path); - if (!$hash) { - $failed++; - $this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})"); + if (!$profile) { continue; } - $migrated++; - $this->line("User {$userId}: migrated ({$hash})"); - } catch (\Throwable $e) { - $failed++; - $this->warn("User {$userId}: migration failed ({$e->getMessage()})"); + // Skip if already migrated unless --force + if (!$force && !empty($profile->avatar_hash)) { + $this->line("[skip] user={$user->id} already migrated"); + continue; + } + + $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}"); - - return $failed > 0 ? self::FAILURE : self::SUCCESS; - } - - private function normalizeLegacyName(?string $value): ?string - { - if (!$value) { - return null; + // 2) Try files named by user id with common extensions + $exts = ['png','jpg','jpeg','webp','gif']; + foreach ($exts as $ext) { + $p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}"; + if (file_exists($p)) { + return $p; + } } - $trimmed = trim($value); - if ($trimmed === '') { - return null; - } - - return basename(urldecode($trimmed)); - } - - private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string - { - $candidates = [ - public_path('avatar/' . $legacyName), - public_path('avatar/' . $userId . '/' . $legacyName), - public_path('user-picture/' . $legacyName), - storage_path('app/public/avatar/' . $legacyName), - storage_path('app/public/avatar/' . $userId . '/' . $legacyName), - storage_path('app/public/user-picture/' . $legacyName), - base_path('oldSite/www/files/usericons/' . $legacyName), - ]; - - foreach ($candidates as $candidate) { - if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) { - return $candidate; + // 3) Try any file under legacy dir that contains the user id in name + if (is_dir($legacyBase)) { + $files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*"); + if (!empty($files)) { + return $files[0]; } } 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; + } + } } + diff --git a/app/Console/Commands/EnforceUsernamePolicy.php b/app/Console/Commands/EnforceUsernamePolicy.php new file mode 100644 index 00000000..6f8aca23 --- /dev/null +++ b/app/Console/Commands/EnforceUsernamePolicy.php @@ -0,0 +1,96 @@ +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; + } +} diff --git a/app/Console/Commands/ForumMigrateOld.php b/app/Console/Commands/ForumMigrateOld.php index 18e0ead6..656f6498 100644 --- a/app/Console/Commands/ForumMigrateOld.php +++ b/app/Console/Commands/ForumMigrateOld.php @@ -4,8 +4,10 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use App\Models\ForumCategory; +use App\Models\User; use App\Models\ForumThread; use App\Models\ForumPost; use Exception; @@ -13,12 +15,19 @@ use App\Services\BbcodeConverter; 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 string $logPath; + protected ?int $limit = null; + + protected ?int $deletedUserId = null; + + /** @var array */ + protected array $missingUserIds = []; + public function __construct() { parent::__construct(); @@ -33,6 +42,17 @@ class ForumMigrateOld extends Command $dry = $this->option('dry-run'); $only = $this->option('only'); $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 { if (!$only || $only === 'categories') { @@ -51,6 +71,10 @@ class ForumMigrateOld extends Command $this->migrateGallery($dry, $chunk); } + if ($this->option('repair-orphans') || $only === 'repair-orphans') { + $this->repairOrphanPosts($dry); + } + if ($this->option('report')) { $this->generateReport(); } @@ -74,8 +98,13 @@ class ForumMigrateOld extends Command ->select('root_id') ->distinct() ->where('root_id', '>', 0) + ->orderBy('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'); foreach ($roots as $rootId) { @@ -90,10 +119,12 @@ class ForumMigrateOld extends Command continue; } - ForumCategory::updateOrCreate( - ['id' => $rootId], - ['name' => $name, 'slug' => $slug] - ); + DB::transaction(function () use ($rootId, $name, $slug) { + ForumCategory::updateOrCreate( + ['id' => $rootId], + ['name' => $name, 'slug' => $slug] + ); + }, 3); } $this->info('Categories migrated'); @@ -107,15 +138,26 @@ class ForumMigrateOld extends Command $query = $legacy->table('forum_topics')->orderBy('topic_id'); $total = $query->count(); + if ($this->limit !== null && $this->limit > 0) { + $total = min($total, $this->limit); + } $this->info("Total threads to process: {$total}"); $bar = $this->output->createProgressBar($total); $bar->start(); + $processed = 0; + $limit = $this->limit; + // 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) { + if ($limit !== null && $limit > 0 && $processed >= $limit) { + return false; + } + $bar->advance(); + $processed++; $data = [ 'id' => $r->topic_id, @@ -137,7 +179,9 @@ class ForumMigrateOld extends Command continue; } - ForumThread::updateOrCreate(['id' => $data['id']], $data); + DB::transaction(function () use ($data) { + ForumThread::updateOrCreate(['id' => $data['id']], $data); + }, 3); } }, 'topic_id'); @@ -153,15 +197,26 @@ class ForumMigrateOld extends Command $query = $legacy->table('forum_posts')->orderBy('post_id'); $total = $query->count(); + if ($this->limit !== null && $this->limit > 0) { + $total = min($total, $this->limit); + } $this->info("Total posts to process: {$total}"); $bar = $this->output->createProgressBar($total); $bar->start(); + $processed = 0; + $limit = $this->limit; + // 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) { + if ($limit !== null && $limit > 0 && $processed >= $limit) { + return false; + } + $bar->advance(); + $processed++; $data = [ 'id' => $r->post_id, @@ -177,7 +232,9 @@ class ForumMigrateOld extends Command continue; } - ForumPost::updateOrCreate(['id' => $data['id']], $data); + DB::transaction(function () use ($data) { + ForumPost::updateOrCreate(['id' => $data['id']], $data); + }, 3); } }, 'post_id'); @@ -243,15 +300,50 @@ class ForumMigrateOld extends Command protected function resolveUserId($userId) { if (empty($userId)) { - return 1; + return $this->resolveDeletedUserId(); } // 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 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) @@ -260,6 +352,114 @@ class ForumMigrateOld extends Command 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() { $this->info('Generating migration report'); @@ -275,12 +475,32 @@ class ForumMigrateOld extends Command 'categories' => ForumCategory::count(), 'threads' => ForumThread::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('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) @@ -301,14 +521,25 @@ class ForumMigrateOld extends Command $query = $legacy->table('forum_topics_gallery')->orderBy('id'); $total = $query->count(); + if ($this->limit !== null && $this->limit > 0) { + $total = min($total, $this->limit); + } $this->info("Total gallery items to process: {$total}"); $bar = $this->output->createProgressBar($total); $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) { + if ($limit !== null && $limit > 0 && $processed >= $limit) { + return false; + } + $bar->advance(); + $processed++; // expected legacy fields: id, name, category (topic id), folder, datum, description $topicId = $r->category ?? ($r->topic_id ?? null); @@ -368,16 +599,21 @@ class ForumMigrateOld extends Command continue; } - \App\Models\ForumAttachment::create([ - 'post_id' => $postId, - 'file_path' => $relativePath, - 'file_size' => $fileSize ?? 0, - 'mime_type' => $mimeType, - 'width' => $width, - 'height' => $height, - 'created_at' => now(), - 'updated_at' => now(), - ]); + DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) { + \App\Models\ForumAttachment::query()->updateOrCreate( + [ + 'post_id' => $postId, + 'file_path' => $relativePath, + ], + [ + 'file_size' => $fileSize ?? 0, + 'mime_type' => $mimeType, + 'width' => $width, + 'height' => $height, + 'updated_at' => now(), + ] + ); + }, 3); } }, 'id'); diff --git a/app/Console/Commands/ImportLegacyUsers.php b/app/Console/Commands/ImportLegacyUsers.php index d870703e..eb1617ed 100644 --- a/app/Console/Commands/ImportLegacyUsers.php +++ b/app/Console/Commands/ImportLegacyUsers.php @@ -3,9 +3,11 @@ 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\Hash; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; class ImportLegacyUsers extends Command @@ -15,9 +17,13 @@ class ImportLegacyUsers extends Command protected array $usedUsernames = []; protected array $usedEmails = []; + protected string $migrationLogPath; 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->usedEmails = User::pluck('email', 'email')->filter()->all(); @@ -56,9 +62,19 @@ class ImportLegacyUsers extends Command protected function importRow($row, $statRow = null): void { $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); + $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); $legacyPassword = $row->password2 ?: $row->password ?: null; @@ -88,6 +104,7 @@ class ImportLegacyUsers extends Command DB::table('users')->insert([ 'id' => $legacyId, 'username' => $username, + 'username_changed_at' => now(), 'name' => $row->real_name ?: $username, 'email' => $email, 'password' => $passwordHash, @@ -126,6 +143,21 @@ class ImportLegacyUsers extends Command 'created_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 { - $username = strtolower(trim($username)); - $username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user'; - return trim($username, '.-') ?: 'user'; + return UsernamePolicy::sanitizeLegacy($username); } protected function uniqueUsername(string $base): string { - $name = $base; - $i = 1; - while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) { - $name = $base . '-' . $i; - $i++; - } + $name = UsernamePolicy::uniqueCandidate($base); $this->usedUsernames[$name] = $name; return $name; } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 46f875ea..f3641d81 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -22,6 +22,7 @@ class Kernel extends ConsoleKernel */ protected $commands = [ ImportLegacyUsers::class, + \App\Console\Commands\EnforceUsernamePolicy::class, ImportCategories::class, MigrateFeaturedWorks::class, \App\Console\Commands\AvatarsMigrate::class, diff --git a/app/Http/Controllers/Api/Admin/UsernameApprovalController.php b/app/Http/Controllers/Api/Admin/UsernameApprovalController.php new file mode 100644 index 00000000..aa01168f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/UsernameApprovalController.php @@ -0,0 +1,149 @@ +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(), + ] + ); + } + } +} diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index a3cee011..fdbf5ac8 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -535,6 +535,7 @@ final class UploadController extends Controller 'upload_id' => (string) $upload->id, 'status' => (string) $upload->status, 'published_at' => optional($upload->published_at)->toISOString(), + 'final_path' => (string) ($upload->final_path ?? ''), ], Response::HTTP_OK); } catch (UploadOwnershipException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); diff --git a/app/Http/Controllers/Api/UsernameAvailabilityController.php b/app/Http/Controllers/Api/UsernameAvailabilityController.php new file mode 100644 index 00000000..e5d5afaa --- /dev/null +++ b/app/Http/Controllers/Api/UsernameAvailabilityController.php @@ -0,0 +1,44 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e8..45b87e65 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -3,23 +3,46 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Mail\RegistrationVerificationMail; use App\Models\User; -use Illuminate\Auth\Events\Registered; +use App\Services\Security\RecaptchaVerifier; +use Carbon\CarbonImmutable; use Illuminate\Http\RedirectResponse; 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\Validation\Rules; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Illuminate\View\View; class RegisteredUserController extends Controller { + public function __construct( + private readonly RecaptchaVerifier $recaptchaVerifier + ) + { + } + /** * 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 { - $request->validate([ - 'name' => ['required', 'string', 'max:255'], + $validated = $request->validate([ '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([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), + 'username' => null, + 'name' => Str::before((string) $validated['email'], '@'), + '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()); } } diff --git a/app/Http/Controllers/Auth/RegistrationVerificationController.php b/app/Http/Controllers/Auth/RegistrationVerificationController.php new file mode 100644 index 00000000..b44f2769 --- /dev/null +++ b/app/Http/Controllers/Auth/RegistrationVerificationController.php @@ -0,0 +1,53 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Auth/SetupPasswordController.php b/app/Http/Controllers/Auth/SetupPasswordController.php new file mode 100644 index 00000000..0e371fac --- /dev/null +++ b/app/Http/Controllers/Auth/SetupPasswordController.php @@ -0,0 +1,45 @@ + (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.'); + } +} diff --git a/app/Http/Controllers/Auth/SetupUsernameController.php b/app/Http/Controllers/Auth/SetupUsernameController.php new file mode 100644 index 00000000..debdba60 --- /dev/null +++ b/app/Http/Controllers/Auth/SetupUsernameController.php @@ -0,0 +1,94 @@ + (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)); + } +} diff --git a/app/Http/Controllers/Community/ForumController.php b/app/Http/Controllers/Community/ForumController.php deleted file mode 100644 index badca315..00000000 --- a/app/Http/Controllers/Community/ForumController.php +++ /dev/null @@ -1,101 +0,0 @@ -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); - } -} diff --git a/app/Http/Controllers/Forum/ForumController.php b/app/Http/Controllers/Forum/ForumController.php new file mode 100644 index 00000000..6e79c09a --- /dev/null +++ b/app/Http/Controllers/Forum/ForumController.php @@ -0,0 +1,348 @@ +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(); + } +} diff --git a/app/Http/Controllers/Legacy/ForumController.php b/app/Http/Controllers/Legacy/ForumController.php deleted file mode 100644 index 984f91e7..00000000 --- a/app/Http/Controllers/Legacy/ForumController.php +++ /dev/null @@ -1,38 +0,0 @@ -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); - } -} diff --git a/app/Http/Controllers/LegacyController.php b/app/Http/Controllers/LegacyController.php index 79cf1f8c..f56f3e01 100644 --- a/app/Http/Controllers/LegacyController.php +++ b/app/Http/Controllers/LegacyController.php @@ -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. */ @@ -437,19 +310,16 @@ class LegacyController extends Controller private function forumNews(): array { try { - return DB::table('forum_topics as t1') - ->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') - ->select( - 't1.topic_id', - 't1.topic', - 't1.views', - 't1.post_date', - 't1.preview', - 't2.uname' - ) - ->where('t1.root_id', 2876) - ->where('t1.privilege', '<', 4) - ->orderByDesc('t1.post_date') + return DB::table('forum_threads as t1') + ->leftJoin('users as t2', 't1.user_id', '=', 't2.id') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.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']) + ->whereNull('t1.deleted_at') + ->where(function ($query) { + $query->where('t1.category_id', 2876) + ->orWhereIn('c.slug', ['news', 'forum-news']); + }) + ->orderByDesc('t1.created_at') ->limit(8) ->get() ->toArray(); @@ -487,17 +357,25 @@ class LegacyController extends Controller private function latestForumActivity(): array { try { - return DB::table('forum_topics as t1') - ->select( - 't1.topic_id', - 't1.topic', - DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') - ) - ->where('t1.root_id', '<>', 0) - ->where('t1.root_id', '<>', 2876) - ->where('t1.privilege', '<', 4) - ->orderByDesc('t1.last_update') - ->orderByDesc('t1.post_date') + return DB::table('forum_threads as t1') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id') + ->leftJoin('forum_posts as p', function ($join) { + $join->on('p.thread_id', '=', 't1.id') + ->whereNull('p.deleted_at'); + }) + ->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) ->get() ->toArray(); diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index ed954c80..3c122b69 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -4,9 +4,15 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; 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\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redirect; use Illuminate\View\View; use Illuminate\Support\Facades\Hash; @@ -14,6 +20,49 @@ use Illuminate\Validation\Rules\Password as PasswordRule; 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 { return view('profile.edit', [ @@ -33,6 +82,56 @@ class ProfileController extends Controller $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)) { $user->email = $validated['email']; $user->email_verified_at = null; @@ -154,4 +253,41 @@ class ProfileController extends Controller 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 ?? ''))), + ]); + } } diff --git a/app/Http/Controllers/User/UserController.php b/app/Http/Controllers/User/UserController.php index dda65e6a..97b2e25c 100644 --- a/app/Http/Controllers/User/UserController.php +++ b/app/Http/Controllers/User/UserController.php @@ -20,7 +20,7 @@ class UserController extends Controller $profile = null; } - return view('user.user', [ + return view('legacy.user', [ 'profile' => $profile, ]); } diff --git a/app/Http/Controllers/Web/HomeController.php b/app/Http/Controllers/Web/HomeController.php index d09f983a..39a82a5f 100644 --- a/app/Http/Controllers/Web/HomeController.php +++ b/app/Http/Controllers/Web/HomeController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use App\Services\ArtworkService; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Log; @@ -29,26 +30,32 @@ class HomeController extends Controller $featured = $featuredResult->getCollection()->first(); } elseif (is_array($featuredResult)) { $featured = $featuredResult[0] ?? null; + } elseif ($featuredResult instanceof Collection) { + $featured = $featuredResult->first(); } else { - $featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult; + $featured = $featuredResult; } $memberFeatured = $featured; $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 { - $forumNews = DB::table('forum_topics as t1') - ->leftJoin('users as u', 't1.user_id', '=', 'u.user_id') - ->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview') - ->where('t1.root_id', 2876) - ->where('t1.privilege', '<', 4) - ->orderBy('t1.post_date', 'desc') + $forumNews = DB::table('forum_threads as t1') + ->leftJoin('users as u', 't1.user_id', '=', 'u.id') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id') + ->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']) + ->whereNull('t1.deleted_at') + ->where(function ($query) { + $query->where('t1.category_id', 2876) + ->orWhereIn('c.slug', ['news', 'forum-news']); + }) + ->orderByDesc('t1.created_at') ->limit(8) ->get(); } 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(); } @@ -66,19 +73,31 @@ class HomeController extends Controller $ourNews = collect(); } - // Latest forum activity (exclude rootless and news root) + // Latest forum activity (exclude forum news category) try { - $latestForumActivity = DB::table('forum_topics as t1') - ->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') - ->where('t1.root_id', '<>', 0) - ->where('t1.root_id', '<>', 2876) - ->where('t1.privilege', '<', 4) - ->orderBy('t1.last_update', 'desc') - ->orderBy('t1.post_date', 'desc') + $latestForumActivity = DB::table('forum_threads as t1') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id') + ->leftJoin('forum_posts as p', function ($join) { + $join->on('p.thread_id', '=', 't1.id') + ->whereNull('p.deleted_at'); + }) + ->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) ->get(); } 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(); } diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php new file mode 100644 index 00000000..d4505abf --- /dev/null +++ b/app/Http/Middleware/EnsureOnboardingComplete.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/app/Http/Middleware/NormalizeUsername.php b/app/Http/Middleware/NormalizeUsername.php new file mode 100644 index 00000000..bec618a7 --- /dev/null +++ b/app/Http/Middleware/NormalizeUsername.php @@ -0,0 +1,28 @@ +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); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index d4815405..adac00a7 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\User; +use App\Support\UsernamePolicy; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,7 +17,7 @@ class ProfileUpdateRequest extends FormRequest public function rules(): array { return [ - 'username' => ['sometimes', 'string', 'max:255'], + 'username' => ['sometimes', ...UsernameRequest::rulesFor((int) $this->user()->id)], 'email' => [ 'required', 'string', @@ -42,4 +43,13 @@ class ProfileUpdateRequest extends FormRequest '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')), + ]); + } + } } diff --git a/app/Http/Requests/UsernameRequest.php b/app/Http/Requests/UsernameRequest.php new file mode 100644 index 00000000..71a16e85 --- /dev/null +++ b/app/Http/Requests/UsernameRequest.php @@ -0,0 +1,73 @@ +has('username')) { + $this->merge([ + 'username' => UsernamePolicy::normalize((string) $this->input('username')), + ]); + } + } + + public function rules(): array + { + return [ + 'username' => self::rulesFor($this->resolveIgnoreUserId()), + ]; + } + + /** + * @return array + */ + public static function rulesFor(?int $ignoreUserId = null): array + { + return [ + ...self::formatRules(), + Rule::unique(User::class, 'username')->ignore($ignoreUserId), + ]; + } + + /** + * @return array + */ + 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; + } +} diff --git a/app/Mail/RegistrationVerificationMail.php b/app/Mail/RegistrationVerificationMail.php new file mode 100644 index 00000000..e90eea0a --- /dev/null +++ b/app/Mail/RegistrationVerificationMail.php @@ -0,0 +1,62 @@ +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), + ]); + } +} diff --git a/app/Models/ForumCategory.php b/app/Models/ForumCategory.php index 8b06c75f..304cf86a 100644 --- a/app/Models/ForumCategory.php +++ b/app/Models/ForumCategory.php @@ -2,7 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; 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 { @@ -14,13 +19,77 @@ class ForumCategory extends Model public $incrementing = true; - public function parent() + public function parent(): BelongsTo { 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'); } + + 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; + } } diff --git a/app/Models/ForumPost.php b/app/Models/ForumPost.php index ce989c09..8e38af65 100644 --- a/app/Models/ForumPost.php +++ b/app/Models/ForumPost.php @@ -2,7 +2,10 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class ForumPost extends Model @@ -18,16 +21,42 @@ class ForumPost extends Model public $incrementing = true; protected $casts = [ + 'is_edited' => 'boolean', 'edited_at' => 'datetime', ]; - public function thread() + public function thread(): BelongsTo { 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'); } + + 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'); + } } diff --git a/app/Models/ForumPostReport.php b/app/Models/ForumPostReport.php new file mode 100644 index 00000000..6c39a841 --- /dev/null +++ b/app/Models/ForumPostReport.php @@ -0,0 +1,40 @@ + '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'); + } +} diff --git a/app/Models/ForumThread.php b/app/Models/ForumThread.php index 0685bcc1..37c6a430 100644 --- a/app/Models/ForumThread.php +++ b/app/Models/ForumThread.php @@ -2,7 +2,10 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class ForumThread extends Model @@ -18,16 +21,43 @@ class ForumThread extends Model public $incrementing = true; protected $casts = [ + 'is_locked' => 'boolean', + 'is_pinned' => 'boolean', 'last_post_at' => 'datetime', ]; - public function category() + public function category(): BelongsTo { 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'); } + + 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); + } } diff --git a/app/Models/User.php b/app/Models/User.php index d6799291..c1556f0f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,8 +21,13 @@ class User extends Authenticatable * @var list */ protected $fillable = [ + 'username', + 'username_changed_at', + 'onboarding_step', 'name', 'email', + 'is_active', + 'needs_password_reset', 'password', 'role', ]; @@ -46,6 +51,7 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'username_changed_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a4f9edd..1d74f260 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,9 @@ use App\Services\Upload\UploadDraftService; use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Illuminate\Queue\Events\JobFailed; class AppServiceProvider extends ServiceProvider { @@ -30,7 +33,9 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureAuthRateLimiters(); $this->configureUploadRateLimiters(); + $this->configureMailFailureLogging(); // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) 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 { RateLimiter::for('uploads-init', function (Request $request): array { diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index dec7289b..9c0fc32e 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -4,7 +4,6 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Category; use App\Models\ContentType; -use App\Models\ArtworkFeature; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -220,14 +219,48 @@ class ArtworkService } } + $categoryIds = $this->categoryAndDescendantIds($current); + $query = $this->browseQuery($sort) - ->whereHas('categories', function ($q) use ($current) { - $q->where('categories.id', $current->id); + ->whereHas('categories', function ($q) use ($categoryIds) { + $q->whereIn('categories.id', $categoryIds); }); return $query->cursorPaginate($perPage); } + /** + * Collect category id plus all descendant category ids. + * + * @return array + */ + 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. * Uses artwork_features table and applies public/approved/published filters. diff --git a/app/Services/LegacyService.php b/app/Services/LegacyService.php index 8da1a174..7916864f 100644 --- a/app/Services/LegacyService.php +++ b/app/Services/LegacyService.php @@ -2,10 +2,9 @@ namespace App\Services; +use App\Models\Artwork; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Log; /** @@ -120,19 +119,16 @@ class LegacyService public function forumNews(): array { try { - return DB::table('forum_topics as t1') - ->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id') - ->select( - 't1.topic_id', - 't1.topic', - 't1.views', - 't1.post_date', - 't1.preview', - 't2.uname' - ) - ->where('t1.root_id', 2876) - ->where('t1.privilege', '<', 4) - ->orderByDesc('t1.post_date') + return DB::table('forum_threads as t1') + ->leftJoin('users as t2', 't1.user_id', '=', 't2.id') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.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']) + ->whereNull('t1.deleted_at') + ->where(function ($query) { + $query->where('t1.category_id', 2876) + ->orWhereIn('c.slug', ['news', 'forum-news']); + }) + ->orderByDesc('t1.created_at') ->limit(8) ->get() ->toArray(); @@ -170,17 +166,25 @@ class LegacyService public function latestForumActivity(): array { try { - return DB::table('forum_topics as t1') - ->select( - 't1.topic_id', - 't1.topic', - DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts') - ) - ->where('t1.root_id', '<>', 0) - ->where('t1.root_id', '<>', 2876) - ->where('t1.privilege', '<', 4) - ->orderByDesc('t1.last_update') - ->orderByDesc('t1.post_date') + return DB::table('forum_threads as t1') + ->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id') + ->leftJoin('forum_posts as p', function ($join) { + $join->on('p.thread_id', '=', 't1.id') + ->whereNull('p.deleted_at'); + }) + ->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) ->get() ->toArray(); @@ -266,7 +270,7 @@ class LegacyService $row->encoded = $encoded; // Prefer new files.skinbase.org when possible try { - $art = \App\Models\Artwork::find($row->id); + $art = Artwork::find($row->id); $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); $row->thumb_url = $present['url']; $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 * Returns null on failure. @@ -555,7 +439,7 @@ class LegacyService $thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg'; // Prefer new CDN when possible try { - $art = \App\Models\Artwork::find($row->id); + $art = Artwork::find($row->id); $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); $thumb_file = $present['url']; $thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null); diff --git a/app/Services/Security/RecaptchaVerifier.php b/app/Services/Security/RecaptchaVerifier.php new file mode 100644 index 00000000..3e2eabfb --- /dev/null +++ b/app/Services/Security/RecaptchaVerifier.php @@ -0,0 +1,51 @@ +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; + } + } +} diff --git a/app/Services/UsernameApprovalService.php b/app/Services/UsernameApprovalService.php new file mode 100644 index 00000000..bedd32e7 --- /dev/null +++ b/app/Services/UsernameApprovalService.php @@ -0,0 +1,48 @@ +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(), + ]); + } +} diff --git a/app/Support/AvatarUrl.php b/app/Support/AvatarUrl.php index 2b460415..c724881a 100644 --- a/app/Support/AvatarUrl.php +++ b/app/Support/AvatarUrl.php @@ -19,9 +19,13 @@ class AvatarUrl 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 diff --git a/app/Support/ForumPostContent.php b/app/Support/ForumPostContent.php new file mode 100644 index 00000000..42c0470e --- /dev/null +++ b/app/Support/ForumPostContent.php @@ -0,0 +1,36 @@ +
Browse Subcategories:
-
    +
      @foreach($subcategories as $sub)
    • {{ $sub->name }}
    • @endforeach diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 3d381860..fc1b58f7 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -1,27 +1,31 @@ - -
      - {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} +@extends('layouts.nova') + +@section('content') +
      +
      +

      Confirm Password

      +

      Please confirm your password before continuing.

      + +
      + @csrf + +
      + + + + + +
      + +
      + + {{ __('Confirm') }} + +
      +
      - -
      - @csrf - - -
      - - - - - -
      - -
      - - {{ __('Confirm') }} - -
      -
      - +
      +@endsection diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index cb32e08f..8721ed76 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -1,25 +1,28 @@ - -
      - {{ __('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.') }} +@extends('layouts.nova') + +@section('content') +
      +
      +

      Reset Password

      +

      Enter your email and we'll send a link to reset your password.

      + + + +
      + @csrf + +
      + + + +
      + +
      + + {{ __('Email Password Reset Link') }} + +
      +
      - - - - -
      - @csrf - - -
      - - - -
      - -
      - - {{ __('Email Password Reset Link') }} - -
      -
      - +
      +@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 78b684f7..df7bd53a 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -1,47 +1,49 @@ - - - +@extends('layouts.nova') -
      - @csrf +@section('content') +
      +
      +

      Log in

      +

      Sign in to continue to your Skinbase account.

      - -
      - - - -
      + - -
      - + + @csrf - +
      + + + +
      - -
      +
      + + + +
      - -
      - -
      +
      + +
      -
      - @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif +
      + @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @else + + @endif - - {{ __('Log in') }} - -
      - - + + {{ __('Log in') }} + +
      + +
      +
      +@endsection diff --git a/resources/views/auth/partials/onboarding-progress.blade.php b/resources/views/auth/partials/onboarding-progress.blade.php new file mode 100644 index 00000000..e981af08 --- /dev/null +++ b/resources/views/auth/partials/onboarding-progress.blade.php @@ -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 + +
      +
      + @foreach($steps as $key => $label) + @php $idx = array_search($key, array_keys($steps), true); @endphp + + {{ $label }} + + @endforeach +
      +
      +
      +
      +
      diff --git a/resources/views/auth/register-notice.blade.php b/resources/views/auth/register-notice.blade.php new file mode 100644 index 00000000..ff02b675 --- /dev/null +++ b/resources/views/auth/register-notice.blade.php @@ -0,0 +1,86 @@ +@extends('layouts.nova') + +@section('content') +
      +
      +
      +

      Check your inbox

      + @if($email !== '') +

      We sent a verification link to {{ $email }}.

      +

      Click the link in that email to continue setup.

      + @else +

      Enter your email to resend verification if needed.

      + @endif +
      + + @if (session('status')) +
      + {{ session('status') }} +
      + @endif + + @if ($errors->any()) +
      + {{ $errors->first() }} +
      + @endif + +
      + @csrf + +
      + + + +
      + +
      + + + + Resend verification email + +
      + +

      +
      +
      +
      + + +@endsection \ No newline at end of file diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index a857242c..619a8f96 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,52 +1,41 @@ - -
      - @csrf +@extends('layouts.nova') - -
      - - - -
      +@section('content') +
      +
      +

      Create Account

      +

      Start with your email. You will set your password and username after verification.

      - -
      - - - -
      + + @csrf - -
      - + - + +
      + + + +
      - -
      + @if(config('services.recaptcha.enabled')) + + + @endif - -
      - + - -
      - - {{ __('Already registered?') }} - - - - {{ __('Register') }} - -
      - - + + {{ __('Register') }} + +
      + +
      +
      +@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index a6494cca..ecdd722a 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -1,39 +1,48 @@ - -
      - @csrf +@extends('layouts.nova') - - +@section('content') +
      +
      +

      Reset Password

      +

      Choose a new password for your account.

      - -
      - - - -
      + + @csrf - -
      - - - -
      + + - -
      - + +
      + + + +
      - + +
      + + + +
      - -
      + +
      + -
      - - {{ __('Reset Password') }} - -
      - - + + + +
      + +
      + + {{ __('Reset Password') }} + +
      + +
      +
      +@endsection diff --git a/resources/views/auth/setup-password.blade.php b/resources/views/auth/setup-password.blade.php new file mode 100644 index 00000000..5e4d94c1 --- /dev/null +++ b/resources/views/auth/setup-password.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.nova') + +@section('content') +
      +
      +

      Set Your Password

      +
      + @include('auth.partials.onboarding-progress', ['currentStep' => 'verified']) + + @if (session('status')) +
      + {{ session('status') }} +
      + @endif + +

      + {{ __('Create a password for ') }}{{ $email }} +

      + +
      + @csrf + +
      + + + +

      {{ __('Minimum 10 characters, include at least one number and one symbol.') }}

      +
      + +
      + + +
      + +
      + + {{ __('Continue') }} + +
      +
      +
      +
      +
      +@endsection diff --git a/resources/views/auth/setup-username.blade.php b/resources/views/auth/setup-username.blade.php new file mode 100644 index 00000000..97e0c18a --- /dev/null +++ b/resources/views/auth/setup-username.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.nova') + +@section('content') +
      +
      +

      Choose Username

      +
      + @include('auth.partials.onboarding-progress', ['currentStep' => 'password']) + + @if (session('status')) +
      + {{ session('status') }} +
      + @endif + + @if ($errors->any()) +
      + {{ $errors->first() }} +
      + @endif + +
      + @csrf + +
      + + +

      + +
      + +
      + + {{ __('Complete Setup') }} + +
      +
      +
      +
      +
      +@endsection diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php index eaf811d1..d2110de0 100644 --- a/resources/views/auth/verify-email.blade.php +++ b/resources/views/auth/verify-email.blade.php @@ -1,31 +1,36 @@ - -
      - {{ __('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.') }} -
      +@extends('layouts.nova') - @if (session('status') == 'verification-link-sent') -
      - {{ __('A new verification link has been sent to the email address you provided during registration.') }} -
      - @endif +@section('content') +
      +
      +

      Verify Your Email

      +

      Before getting started, please verify your email address by clicking the link we sent you.

      -
      -
      - @csrf - -
      - - {{ __('Resend Verification Email') }} - + @if (session('status') == 'verification-link-sent') +
      + {{ __('A new verification link has been sent to the email address you provided during registration.') }}
      - + @endif -
      - @csrf +
      + + @csrf - - +
      + + {{ __('Resend Verification Email') }} + +
      + + +
      + @csrf + + +
      +
      - +
      +@endsection diff --git a/resources/views/community/forum/index.blade.php b/resources/views/community/forum/index.blade.php deleted file mode 100644 index e0c748a3..00000000 --- a/resources/views/community/forum/index.blade.php +++ /dev/null @@ -1,63 +0,0 @@ -@extends('layouts.nova') - -@php - use Carbon\Carbon; - use Illuminate\Support\Str; -@endphp - -@section('content') -
      -
      -

      Forum

      -

      Browse forum sections and latest activity.

      -
      - -
      -
      Forum Sections
      - -
      - - - - - - - - - - - @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 - - - - - - - @empty - - - - @endforelse - -
      SectionPostsTopicsLast Update
      - {{ $topicTitle }} - @if (!empty($topic->discuss)) -
      {!! Str::limit(strip_tags((string) $topic->discuss), 180) !!}
      - @endif -
      {{ $topic->num_posts ?? 0 }}{{ $topic->num_subtopics ?? 0 }} - @if (!empty($topic->last_update)) - {{ Carbon::parse($topic->last_update)->format('d.m.Y H:i') }} - @else - - - @endif -
      No forum sections available.
      -
      -
      -
      -@endsection diff --git a/resources/views/community/forum/posts.blade.php b/resources/views/community/forum/posts.blade.php deleted file mode 100644 index 37364a58..00000000 --- a/resources/views/community/forum/posts.blade.php +++ /dev/null @@ -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 - -
      -
      - ← Back to forum -

      {{ $headerTitle }}

      - @if (!empty($headerDesc)) -

      {!! Str::limit(strip_tags((string) $headerDesc), 260) !!}

      - @endif -
      - -
      - @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 - -
      -
      -
      {{ $authorName }}
      -
      - @if (!empty($postedAt)) - {{ Carbon::parse($postedAt)->format('d.m.Y H:i') }} - @endif -
      -
      - -
      -
      - {!! $postBody !!} -
      - - @if (!empty($authorId)) -
      - User ID: {{ $authorId }} -
      - @endif -
      -
      - @empty -
      - No posts yet. -
      - @endforelse -
      - - @if (isset($posts) && method_exists($posts, 'links')) -
      {{ $posts->withQueryString()->links() }}
      - @endif -
      -@endsection diff --git a/resources/views/components/forum/category-card.blade.php b/resources/views/components/forum/category-card.blade.php new file mode 100644 index 00000000..03781629 --- /dev/null +++ b/resources/views/components/forum/category-card.blade.php @@ -0,0 +1 @@ +@include('forum.components.category-card', ['category' => $category]) diff --git a/resources/views/components/forum/thread/attachment-list.blade.php b/resources/views/components/forum/thread/attachment-list.blade.php new file mode 100644 index 00000000..1be7aaa7 --- /dev/null +++ b/resources/views/components/forum/thread/attachment-list.blade.php @@ -0,0 +1 @@ +@include('forum.thread.components.attachment-list', ['attachments' => $attachments]) diff --git a/resources/views/components/forum/thread/author-badge.blade.php b/resources/views/components/forum/thread/author-badge.blade.php new file mode 100644 index 00000000..bb3ba262 --- /dev/null +++ b/resources/views/components/forum/thread/author-badge.blade.php @@ -0,0 +1 @@ +@include('forum.thread.components.author-badge', ['user' => $user]) diff --git a/resources/views/components/forum/thread/breadcrumbs.blade.php b/resources/views/components/forum/thread/breadcrumbs.blade.php new file mode 100644 index 00000000..7dd4f557 --- /dev/null +++ b/resources/views/components/forum/thread/breadcrumbs.blade.php @@ -0,0 +1 @@ +@include('forum.thread.components.breadcrumbs', ['thread' => $thread, 'category' => $category]) diff --git a/resources/views/components/forum/thread/post-card.blade.php b/resources/views/components/forum/thread/post-card.blade.php new file mode 100644 index 00000000..a8cf68e7 --- /dev/null +++ b/resources/views/components/forum/thread/post-card.blade.php @@ -0,0 +1 @@ +@include('forum.thread.components.post-card', ['post' => $post, 'thread' => $thread ?? null, 'isOp' => $isOp ?? false]) diff --git a/resources/views/emails/registration-verification.blade.php b/resources/views/emails/registration-verification.blade.php new file mode 100644 index 00000000..4cdf653c --- /dev/null +++ b/resources/views/emails/registration-verification.blade.php @@ -0,0 +1,39 @@ + + + + + + Verify your email + + + + + + +
      + + + + + + + + + + +
      +

      {{ config('app.name', 'Skinbase') }}

      +
      +

      Welcome to {{ config('app.name', 'Skinbase') }} — thanks for signing up.

      +

      Please verify your email to continue account setup.

      + + + +

      This link expires in {{ $expiresInHours }} hours.

      +

      Need help? Contact support: {{ $supportUrl }}

      +
      © {{ date('Y') }} {{ config('app.name', 'Skinbase') }}. All rights reserved.
      +
      + + diff --git a/resources/views/forum/community/edit-post.blade.php b/resources/views/forum/community/edit-post.blade.php new file mode 100644 index 00000000..5c3b114f --- /dev/null +++ b/resources/views/forum/community/edit-post.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.nova') + +@section('content') +
      +
      + ← Back to thread +

      Edit post

      +
      + +
      + @csrf + @method('PUT') + +
      + + + @error('content') +
      {{ $message }}
      + @enderror +
      + +
      + +
      +
      +
      +@endsection diff --git a/resources/views/forum/community/new-thread.blade.php b/resources/views/forum/community/new-thread.blade.php new file mode 100644 index 00000000..a43a282d --- /dev/null +++ b/resources/views/forum/community/new-thread.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.nova') + +@section('content') +
      +
      + ← Back to section +

      Create thread in {{ $category->name }}

      +
      + +
      + @csrf + +
      + + + @error('title') +
      {{ $message }}
      + @enderror +
      + +
      + + + @error('content') +
      {{ $message }}
      + @enderror +
      + +
      + +
      +
      +
      +@endsection diff --git a/resources/views/community/forum/topic.blade.php b/resources/views/forum/community/topic.blade.php similarity index 85% rename from resources/views/community/forum/topic.blade.php rename to resources/views/forum/community/topic.blade.php index 169c66b7..111da82f 100644 --- a/resources/views/community/forum/topic.blade.php +++ b/resources/views/forum/community/topic.blade.php @@ -8,11 +8,16 @@ @section('content')
      - ← Back to forum + ← Back to forum

      {{ $topic->topic ?? $topic->title ?? 'Topic' }}

      @if (!empty($topic->discuss))

      {!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}

      @endif + @if (isset($category) && auth()->check()) + + @endif
      @@ -35,7 +40,7 @@ @endphp - {{ $title }} + {{ $title }} @if (!empty($sub->discuss))
      {!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}
      @endif diff --git a/resources/views/forum/components/category-card.blade.php b/resources/views/forum/components/category-card.blade.php new file mode 100644 index 00000000..ba490259 --- /dev/null +++ b/resources/views/forum/components/category-card.blade.php @@ -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 + + +
      + {{ $name }} preview +
      + +
      +
      + +
      + +

      {{ $name }}

      +

      + Last activity: + @if ($lastActivity) + + @else + No activity yet + @endif +

      + +
      + {{ number_format($posts) }} posts + {{ number_format($threads) }} topics +
      +
      +
      +
      diff --git a/resources/views/forum/index.blade.php b/resources/views/forum/index.blade.php new file mode 100644 index 00000000..468ba812 --- /dev/null +++ b/resources/views/forum/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.nova') + +@section('content') +
      +
      +
      +

      Forum

      +

      Browse forum sections and latest activity.

      +
      + + @if (($categories ?? collect())->isEmpty()) +
      + No forum categories available yet. +
      + @else +
      + @foreach ($categories as $category) + + @endforeach +
      + @endif +
      +
      +@endsection diff --git a/resources/views/forum/thread/components/attachment-list.blade.php b/resources/views/forum/thread/components/attachment-list.blade.php new file mode 100644 index 00000000..7b07d380 --- /dev/null +++ b/resources/views/forum/thread/components/attachment-list.blade.php @@ -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()) +
      +

      Attachments

      +
        + @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 +
      • + @if ($isImage) + + Attachment preview + + @endif + +
        + {{ basename((string) ($attachment->file_path ?? 'file')) }} + {{ $formatBytes($attachment->file_size ?? 0) }} +
        + + + Download + + + @if ($isImage) + + @endif +
      • + @endforeach +
      +
      +@endif diff --git a/resources/views/forum/thread/components/author-badge.blade.php b/resources/views/forum/thread/components/author-badge.blade.php new file mode 100644 index 00000000..794dce90 --- /dev/null +++ b/resources/views/forum/thread/components/author-badge.blade.php @@ -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 + +
      + {{ $name }} avatar +
      +
      {{ $name }}
      + {{ $roleLabel }} +
      +
      diff --git a/resources/views/forum/thread/components/breadcrumbs.blade.php b/resources/views/forum/thread/components/breadcrumbs.blade.php new file mode 100644 index 00000000..ca610304 --- /dev/null +++ b/resources/views/forum/thread/components/breadcrumbs.blade.php @@ -0,0 +1,30 @@ +@php + $thread = $thread ?? null; + $category = $category ?? null; +@endphp + + diff --git a/resources/views/forum/thread/components/post-card.blade.php b/resources/views/forum/thread/components/post-card.blade.php new file mode 100644 index 00000000..6e014245 --- /dev/null +++ b/resources/views/forum/thread/components/post-card.blade.php @@ -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 + +
      +
      +
      + +
      + @if ($postedAt) + + @endif + @if ($isOp) + OP + @endif +
      +
      +
      + +
      +
      + {!! $rendered !!} +
      + + @if (data_get($post, 'is_edited') && $editedAt) +

      + Edited +

      + @endif + + +
      + +
      + + @if (!empty(data_get($thread, 'id'))) + Quote + @else + Quote + @endif + @auth + @if ((int) data_get($post, 'user_id') !== (int) auth()->id()) +
      + @csrf + +
      + @endif + @else + Report + @endauth + + @auth + @if ((int) data_get($post, 'user_id') === (int) auth()->id() || Gate::allows('moderate-forum')) + Edit + @endif + @endauth + + @can('moderate-forum') + Moderation tools available + @endcan +
      +
      diff --git a/resources/views/forum/thread/show.blade.php b/resources/views/forum/thread/show.blade.php new file mode 100644 index 00000000..bd32e7da --- /dev/null +++ b/resources/views/forum/thread/show.blade.php @@ -0,0 +1,124 @@ +@extends('layouts.nova') + +@section('content') +
      +
      + + + @if (session('status')) +
      + {{ session('status') }} +
      + @endif + +
      +
      +
      +

      {{ $thread->title }}

      +
      + By {{ $author->name ?? 'Unknown' }} + + +
      +
      + +
      + {{ number_format((int) ($thread->views ?? 0)) }} views + {{ number_format((int) ($reply_count ?? 0)) }} replies + @if ($thread->is_pinned) + Pinned + @endif + @if ($thread->is_locked) + Locked + @endif +
      +
      + + @can('moderate-forum') +
      + @if ($thread->is_locked) +
      + @csrf + +
      + @else +
      + @csrf + +
      + @endif + + @if ($thread->is_pinned) +
      + @csrf + +
      + @else +
      + @csrf + +
      + @endif +
      + @endcan +
      + + @if (isset($opPost) && $opPost) + + @endif + +
      + @forelse ($posts as $post) + + @empty +
      + No replies yet. +
      + @endforelse +
      + + @if (method_exists($posts, 'links')) +
      + {{ $posts->withQueryString()->links() }} +
      + @endif + + @auth + @if (!$thread->is_locked) +
      + @csrf +
      + + Minimum 2 characters +
      +
      +
      + + Preview (coming soon) +
      + +
      + @error('content') +

      {{ $message }}

      + @enderror + @if (!empty($quoted_post)) +

      Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.

      + @endif +
      +

      Markdown/BBCode + attachments will be enabled in next pass

      + +
      +
      + @else +
      + This thread is locked. Replies are disabled. +
      + @endif + @else +
      + Sign in to post a reply. +
      + @endauth +
      +
      +@endsection diff --git a/resources/views/gallery/index.blade.php b/resources/views/gallery/index.blade.php index e0509b59..bea0e9f2 100644 --- a/resources/views/gallery/index.blade.php +++ b/resources/views/gallery/index.blade.php @@ -10,18 +10,25 @@
      -
      +
      + +