Auth: convert auth views and verification email to Nova layout

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

View File

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

View File

@@ -535,6 +535,7 @@ final class UploadController extends Controller
'upload_id' => (string) $upload->id,
'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);

View File

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

View File

@@ -3,23 +3,46 @@
namespace App\Http\Controllers\Auth;
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());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
return view('legacy.forum.index', $data);
}
public function topic(Request $request, $topic_id)
{
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
return view('legacy.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('legacy.forum.topic', $data);
}
return view('legacy.forum.posts', $data);
}
}

View File

@@ -255,133 +255,6 @@ class LegacyController extends Controller
));
}
public function forumIndex()
{
$page_title = 'Forum';
$page_meta_description = 'Skinbase forum threads.';
$page_meta_keywords = 'forum, discussions, topics, skinbase';
try {
$topics = DB::table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return view('legacy.forum.index', compact(
'topics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumTopic(Request $request, int $topic_id)
{
try {
$topic = DB::table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (!$topic) {
return redirect('/forum');
}
$page_title = $topic->topic;
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
$page_meta_keywords = 'forum, topic, skinbase';
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
try {
$subtopics = DB::table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
if ($subtopics->total() > 0) {
return view('legacy.forum.topic', compact(
'topic',
'subtopics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
// First try topic_id; if empty, retry using legacy tid column
$posts = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// will retry with tid
}
if ($posts->total() === 0) {
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// keep empty paginator
}
}
return view('legacy.forum.posts', compact(
'topic',
'posts',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
/**
* Fetch featured artworks with graceful fallbacks.
*/
@@ -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();

View File

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

View File

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

View File

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