Auth: convert auth views and verification email to Nova layout
This commit is contained in:
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal 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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal file
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal file
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal file
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
348
app/Http/Controllers/Forum/ForumController.php
Normal file
348
app/Http/Controllers/Forum/ForumController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ?? ''))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserController extends Controller
|
||||
$profile = null;
|
||||
}
|
||||
|
||||
return view('user.user', [
|
||||
return view('legacy.user', [
|
||||
'profile' => $profile,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOnboardingComplete
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
if ($step === 'complete') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$target = match ($step) {
|
||||
'email' => '/login',
|
||||
'verified' => '/setup/password',
|
||||
'password', 'username' => '/setup/username',
|
||||
default => '/setup/password',
|
||||
};
|
||||
|
||||
if ($request->is(ltrim($target, '/'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/NormalizeUsername.php
Normal file
28
app/Http/Middleware/NormalizeUsername.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NormalizeUsername
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$payload = $request->all();
|
||||
|
||||
if (array_key_exists('username', $payload)) {
|
||||
$payload['username'] = UsernamePolicy::normalize((string) $payload['username']);
|
||||
}
|
||||
|
||||
if (array_key_exists('old_username', $payload)) {
|
||||
$payload['old_username'] = UsernamePolicy::normalize((string) $payload['old_username']);
|
||||
}
|
||||
|
||||
$request->merge($payload);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Http/Requests/UsernameRequest.php
Normal file
73
app/Http/Requests/UsernameRequest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UsernameRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('username')) {
|
||||
$this->merge([
|
||||
'username' => UsernamePolicy::normalize((string) $this->input('username')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => self::rulesFor($this->resolveIgnoreUserId()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public static function rulesFor(?int $ignoreUserId = null): array
|
||||
{
|
||||
return [
|
||||
...self::formatRules(),
|
||||
Rule::unique(User::class, 'username')->ignore($ignoreUserId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public static function formatRules(): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'min:' . UsernamePolicy::min(),
|
||||
'max:' . UsernamePolicy::max(),
|
||||
'regex:' . UsernamePolicy::regex(),
|
||||
Rule::notIn(UsernamePolicy::reserved()),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveIgnoreUserId(): ?int
|
||||
{
|
||||
$user = $this->user();
|
||||
if ($user) {
|
||||
return (int) $user->id;
|
||||
}
|
||||
|
||||
$routeUserId = $this->route('id') ?? $this->route('user');
|
||||
if (is_numeric($routeUserId)) {
|
||||
return (int) $routeUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user