mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 23:01:29 +00:00
411 lines
14 KiB
PHP
411 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Enums\UserRole;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Auth\RegisterRegisterRequest;
|
|
use App\Models\Invitation;
|
|
use App\Models\User;
|
|
use App\Rules\ValidEmailDomain;
|
|
use App\Services\RegistrationStatusService;
|
|
use App\Support\Auth\RegistersUsers;
|
|
use App\Support\PermissionSyncHelper;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Redirector;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\Rules\Password;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Spatie\Permission\Models\Role;
|
|
use Throwable;
|
|
|
|
class RegisterController extends Controller
|
|
{
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Register Controller
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| This controller handles the registration of new users as well as their
|
|
| validation and creation. By default this controller uses a trait to
|
|
| provide this functionality without requiring any additional code.
|
|
|
|
|
*/
|
|
|
|
use RegistersUsers;
|
|
|
|
/**
|
|
* Where to redirect users after registration.
|
|
*/
|
|
protected string $redirectTo = '/';
|
|
|
|
private string $inviteCodeQuery = '';
|
|
|
|
public function __construct(
|
|
private readonly RegistrationStatusService $registrationStatusService
|
|
) {
|
|
$this->middleware('guest');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
protected function create(array $data): User
|
|
{
|
|
$user = User::create([
|
|
'username' => $data['username'],
|
|
'email' => $data['email'],
|
|
'password' => User::hashPassword($data['password']),
|
|
'host' => $data['host'] ?? '',
|
|
'roles_id' => $data['roles_id'],
|
|
'notes' => $data['notes'],
|
|
'invites' => $data['defaultinvites'],
|
|
'api_token' => md5(Str::random(40)),
|
|
'verified' => false,
|
|
'email_verified_at' => null,
|
|
]);
|
|
|
|
$role = Role::query()->where('id', '=', $data['roles_id'])->first();
|
|
|
|
if ($role !== null) {
|
|
$user->syncRoles([$role->name]);
|
|
PermissionSyncHelper::grantInheritedPermissions($user);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* @return RedirectResponse|Redirector|void
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
public function register(RegisterRegisterRequest $request)
|
|
{
|
|
$error = '';
|
|
$showRegister = 1;
|
|
$inviteCode = $this->extractInviteCode($request);
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
|
|
'email' => [
|
|
'required',
|
|
'string',
|
|
'email',
|
|
'max:255',
|
|
Rule::unique('users', 'email')->where(fn ($query) => $query->whereNull('deleted_at')),
|
|
app(ValidEmailDomain::class),
|
|
],
|
|
'password' => ['required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()->uncompromised()],
|
|
], [
|
|
'password.min' => 'The password must be at least 8 characters.',
|
|
'password.letters' => 'The password must contain letters.',
|
|
'password.mixed_case' => 'The password must contain both uppercase and lowercase letters.',
|
|
'password.numbers' => 'The password must contain at least one number.',
|
|
'password.symbols' => 'The password must contain at least one symbol.',
|
|
'password.uncompromised' => 'The password appears in a data breach and should not be used.',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
$this->logRegistrationFailure(
|
|
$request,
|
|
'validation_failed',
|
|
'The registration request failed validation.',
|
|
'warning',
|
|
['validation_errors' => $validator->errors()->toArray()]
|
|
);
|
|
|
|
return redirect()->back()
|
|
->withErrors($validator)
|
|
->withInput($request->except('password', 'password_confirmation'));
|
|
}
|
|
|
|
$action = $request->input('action') ?? 'view';
|
|
|
|
switch ($action) {
|
|
case 'submit':
|
|
$userName = (string) $request->input('username');
|
|
$password = (string) $request->input('password');
|
|
$email = (string) $request->input('email');
|
|
|
|
// Check if the email is associated with a soft-deleted user
|
|
$existingUser = User::withTrashed()->where('email', $email)->first();
|
|
if ($existingUser && $existingUser->trashed()) {
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
'This email address belongs to a deactivated account. Please contact us if you need help reactivating it.',
|
|
'deactivated_account'
|
|
);
|
|
}
|
|
|
|
// Get the default user role.
|
|
$userDefault = Role::query()->where('isdefault', '=', 1)->first();
|
|
|
|
$status = $this->registrationStatusService->resolve();
|
|
$invitationValid = $this->isInvitationValid($inviteCode, $email);
|
|
|
|
if ($status['is_open'] || ($status['is_invite_only'] && $invitationValid)) {
|
|
$invitation = null;
|
|
|
|
if ($invitationValid) {
|
|
$invitation = Invitation::findValidByToken($inviteCode);
|
|
}
|
|
|
|
$userData = [
|
|
'username' => $userName,
|
|
'password' => $password,
|
|
'email' => $email,
|
|
'host' => $request->ip(),
|
|
'roles_id' => $userDefault !== null ? $userDefault['id'] : UserRole::USER->value,
|
|
'notes' => '',
|
|
'defaultinvites' => $userDefault !== null ? $userDefault['defaultinvites'] : Invitation::DEFAULT_INVITES,
|
|
];
|
|
|
|
// Apply invitation metadata if available
|
|
if ($invitation && $invitation->metadata) {
|
|
if (isset($invitation->metadata['role'])) {
|
|
$userData['roles_id'] = $invitation->metadata['role'];
|
|
}
|
|
}
|
|
|
|
try {
|
|
DB::transaction(function () use ($invitation, $userData): void {
|
|
$user = $this->create($userData);
|
|
|
|
if ($invitation && ! $invitation->markAsUsed($user->id)) {
|
|
throw new \RuntimeException('Failed to mark invitation as used.');
|
|
}
|
|
});
|
|
} catch (Throwable $exception) {
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
'We could not complete your registration right now. Please try again in a few minutes. If the problem continues, contact support.',
|
|
'unexpected_exception',
|
|
'error',
|
|
[
|
|
'error' => $exception->getMessage(),
|
|
'exception' => $exception::class,
|
|
]
|
|
);
|
|
}
|
|
|
|
return redirect()
|
|
->route('login')
|
|
->with('info', 'Your account has been created. You will receive an account confirmation email shortly. Please verify your email address before logging in.');
|
|
}
|
|
|
|
return $this->rejectUnavailableRegistration($request, $status, $inviteCode);
|
|
|
|
case 'view':
|
|
// Don't set showRegister here - let showRegistrationForm handle it
|
|
// Only validate invite code if present
|
|
if ($inviteCode !== '' && ! $this->isInvitationTokenValid($inviteCode)) {
|
|
$error = 'Invalid invitation token!';
|
|
}
|
|
break;
|
|
}
|
|
|
|
return $this->showRegistrationForm($request, $error, $showRegister);
|
|
}
|
|
|
|
public function showRegistrationForm(Request $request, string $error = '', int $showRegister = 0): mixed
|
|
{
|
|
$inviteCode = $this->extractInviteCode($request);
|
|
|
|
$emailFromInvite = '';
|
|
$notice = '';
|
|
$status = $this->registrationStatusService->resolve();
|
|
|
|
if ($status['is_open']) {
|
|
$showRegister = 1;
|
|
|
|
if (! empty($inviteCode)) {
|
|
$invitation = Invitation::findValidByToken($inviteCode);
|
|
if ($invitation && ! empty($invitation->email)) {
|
|
$emailFromInvite = $invitation->email;
|
|
} elseif (! $this->isInvitationTokenValid($inviteCode)) {
|
|
$notice = 'Registration is currently open, so you can register without a valid invitation.';
|
|
}
|
|
}
|
|
|
|
if ($status['scheduled_override_active']) {
|
|
$notice = $status['message'];
|
|
}
|
|
} elseif ($status['is_invite_only']) {
|
|
if (! empty($inviteCode) && $this->isInvitationTokenValid($inviteCode)) {
|
|
$showRegister = 1;
|
|
|
|
$invitation = Invitation::findValidByToken($inviteCode);
|
|
if ($invitation && ! empty($invitation->email)) {
|
|
$emailFromInvite = $invitation->email;
|
|
}
|
|
} elseif (! empty($inviteCode)) {
|
|
$error = 'Invalid or expired invitation token!';
|
|
$showRegister = 0;
|
|
} else {
|
|
$error = $status['message'];
|
|
$showRegister = 0;
|
|
}
|
|
} else {
|
|
$error = $status['message'];
|
|
$showRegister = 0;
|
|
}
|
|
|
|
return view('auth.register')->with([
|
|
'showregister' => $showRegister,
|
|
'error' => $error,
|
|
'notice' => $notice,
|
|
'email' => $emailFromInvite,
|
|
'invite_code_query' => $this->inviteCodeQuery,
|
|
'invitecode' => $inviteCode,
|
|
'registrationStatus' => $status,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check if invitation is valid for given email
|
|
*/
|
|
private function isInvitationValid(string $token, string $email): bool
|
|
{
|
|
if (empty($token)) {
|
|
return false;
|
|
}
|
|
|
|
$invitation = Invitation::findValidByToken($token);
|
|
|
|
if (! $invitation) {
|
|
return false;
|
|
}
|
|
|
|
// If invitation has specific email, validate it matches
|
|
if (! empty($invitation->email) && ! $this->emailsMatch($invitation->email, $email)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if invitation token is valid (without email check)
|
|
*/
|
|
private function isInvitationTokenValid(string $token): bool
|
|
{
|
|
if (empty($token)) {
|
|
return false;
|
|
}
|
|
|
|
$invitation = Invitation::findValidByToken($token);
|
|
|
|
return $invitation !== null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function redirectBackWithRegistrationError(
|
|
Request $request,
|
|
string $message,
|
|
string $reason = 'registration_failed',
|
|
string $level = 'warning',
|
|
array $context = []
|
|
): RedirectResponse {
|
|
$this->logRegistrationFailure($request, $reason, $message, $level, $context);
|
|
|
|
return redirect()
|
|
->back()
|
|
->withErrors(['registration' => $message])
|
|
->withInput($request->except('password', 'password_confirmation'));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function logRegistrationFailure(
|
|
Request $request,
|
|
string $reason,
|
|
string $message,
|
|
string $level = 'warning',
|
|
array $context = []
|
|
): void {
|
|
$status = $this->registrationStatusService->resolve();
|
|
|
|
$logContext = array_merge([
|
|
'reason' => $reason,
|
|
'message' => $message,
|
|
'username' => $request->input('username'),
|
|
'email' => $request->input('email'),
|
|
'ip' => $request->ip(),
|
|
'registration_status' => $status['effective_status'],
|
|
'manual_registration_status' => $status['manual_status'],
|
|
'registration_status_reason' => $status['reason'],
|
|
'has_invite_code' => $request->filled('invitecode') || $request->filled('token') || $request->filled('invitation'),
|
|
], $context);
|
|
|
|
Log::channel('registration')->{$level}("Registration attempt failed: {$reason}", $logContext);
|
|
}
|
|
|
|
/**
|
|
* @return RedirectResponse|Redirector
|
|
*/
|
|
private function rejectUnavailableRegistration(Request $request, array $status, string $inviteCode)
|
|
{
|
|
if ($status['is_closed']) {
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
$status['message'],
|
|
'registrations_closed'
|
|
);
|
|
}
|
|
|
|
if ($status['is_invite_only']) {
|
|
if (! empty($inviteCode) && ! $this->isInvitationTokenValid($inviteCode)) {
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
'Invalid or expired invitation token!',
|
|
'invalid_or_expired_invitation'
|
|
);
|
|
}
|
|
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
$status['message'],
|
|
'registrations_invite_only'
|
|
);
|
|
}
|
|
|
|
return $this->redirectBackWithRegistrationError(
|
|
$request,
|
|
'Registrations are not currently available.',
|
|
'registrations_unavailable'
|
|
);
|
|
}
|
|
|
|
private function extractInviteCode(Request $request): string
|
|
{
|
|
$this->inviteCodeQuery = '';
|
|
|
|
foreach (['invitecode', 'token', 'invitation'] as $parameter) {
|
|
if ($request->filled($parameter)) {
|
|
$inviteCode = (string) $request->input($parameter);
|
|
$this->inviteCodeQuery = '&'.$parameter.'='.$inviteCode;
|
|
|
|
return $inviteCode;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function emailsMatch(string $firstEmail, string $secondEmail): bool
|
|
{
|
|
return strcasecmp(trim($firstEmail), trim($secondEmail)) === 0;
|
|
}
|
|
}
|