diff --git a/.env.example b/.env.example index 536d5299e..6dc6e770b 100644 --- a/.env.example +++ b/.env.example @@ -103,10 +103,15 @@ PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -NOCAPTCHA_ENABLED= +CAPTCHA_PROVIDER=recaptcha +NOCAPTCHA_ENABLED=false NOCAPTCHA_SITEKEY= NOCAPTCHA_SECRET= +TURNSTILE_SECRET= +TURNSTILE_SITEKEY= +TURNSTILE_ENABLED=false + SCOUT_DRIVER=database SCOUT_QUEUE=true diff --git a/CAPTCHA_CONFIGURATION.md b/CAPTCHA_CONFIGURATION.md new file mode 100644 index 000000000..dd247bd4a --- /dev/null +++ b/CAPTCHA_CONFIGURATION.md @@ -0,0 +1,157 @@ +# CAPTCHA Configuration Guide + +This application supports two CAPTCHA providers: +- **Google reCAPTCHA v2** +- **Cloudflare Turnstile** + +## Important: Only One Provider at a Time + +**You can only enable ONE captcha provider at a time.** The system will use the provider specified in `CAPTCHA_PROVIDER` environment variable. + +## Configuration + +### 1. Choose Your Provider + +In your `.env` file, set the provider: + +```env +CAPTCHA_PROVIDER=recaptcha # or 'turnstile' +``` + +### 2. Google reCAPTCHA Configuration + +To use Google reCAPTCHA: + +```env +CAPTCHA_PROVIDER=recaptcha +NOCAPTCHA_ENABLED=true +NOCAPTCHA_SITEKEY=your_recaptcha_sitekey +NOCAPTCHA_SECRET=your_recaptcha_secret + +# Disable Turnstile +TURNSTILE_ENABLED=false +``` + +**How to get reCAPTCHA keys:** +1. Visit [Google reCAPTCHA Admin](https://www.google.com/recaptcha/admin) +2. Register a new site with reCAPTCHA v2 (Checkbox) +3. Copy the Site Key and Secret Key + +### 3. Cloudflare Turnstile Configuration + +To use Cloudflare Turnstile: + +```env +CAPTCHA_PROVIDER=turnstile +TURNSTILE_ENABLED=true +TURNSTILE_SITEKEY=your_turnstile_sitekey +TURNSTILE_SECRET=your_turnstile_secret + +# Disable reCAPTCHA +NOCAPTCHA_ENABLED=false +``` + +**How to get Turnstile keys:** +1. Visit [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. Go to Turnstile section +3. Create a new site +4. Copy the Site Key and Secret Key + +## Features + +### Where CAPTCHA is Used + +CAPTCHA verification is applied to: +- **User Registration** (`/register`) +- **User Login** (`/login`) +- **Password Reset** (`/password/reset`) +- **Contact Form** (`/contact`) + +### Switching Between Providers + +To switch from one provider to another: + +1. Update `CAPTCHA_PROVIDER` in `.env` +2. Enable the new provider and disable the old one +3. Clear config cache: `php artisan config:clear` + +Example - Switching from reCAPTCHA to Turnstile: + +```env +# Change this +CAPTCHA_PROVIDER=turnstile + +# Enable Turnstile +TURNSTILE_ENABLED=true +TURNSTILE_SITEKEY=your_turnstile_sitekey +TURNSTILE_SECRET=your_turnstile_secret + +# Disable reCAPTCHA +NOCAPTCHA_ENABLED=false +``` + +## Disabling CAPTCHA + +To completely disable CAPTCHA protection: + +```env +NOCAPTCHA_ENABLED=false +TURNSTILE_ENABLED=false +``` + +## Testing + +### Test Keys + +**Google reCAPTCHA Test Keys:** +- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` +- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` + +**Cloudflare Turnstile Test Keys:** +- Site Key: `1x00000000000000000000AA` (Always passes) +- Secret Key: `1x0000000000000000000000000000000AA` + +## Troubleshooting + +### CAPTCHA Not Showing + +1. Check if CAPTCHA is enabled in `.env` +2. Verify you have both sitekey and secret configured +3. Clear config cache: `php artisan config:clear` +4. Check browser console for JavaScript errors + +### Validation Failing + +1. Verify the secret key is correct +2. Check if the domain is allowed in your CAPTCHA provider settings +3. Check application logs for detailed error messages + +### Both Providers Enabled Warning + +If you see a warning in logs about both providers being enabled, ensure you have: +- Set `CAPTCHA_PROVIDER` to either `recaptcha` or `turnstile` +- Disabled the provider you're not using by setting its `ENABLED` flag to `false` + +## Technical Details + +### Files Modified + +- **Config:** `config/captcha.php` - Main configuration +- **Service:** `app/Services/TurnstileService.php` - Turnstile API integration +- **Helper:** `app/Support/CaptchaHelper.php` - Unified captcha interface +- **Rule:** `app/Rules/TurnstileRule.php` - Turnstile validation rule +- **Views:** Updated to use dynamic captcha provider +- **Requests:** Updated validation to support both providers + +### API Endpoints + +- **reCAPTCHA:** `https://www.google.com/recaptcha/api/siteverify` +- **Turnstile:** `https://challenges.cloudflare.com/turnstile/v0/siteverify` + +## Security Notes + +- Never commit your production keys to version control +- Use environment-specific `.env` files +- Regularly rotate your secret keys +- Monitor failed CAPTCHA attempts in logs + diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index aa2982cd6..d36185e78 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -530,3 +530,20 @@ if (! function_exists('formatBytes')) { return round($bytes, 2).' '.$units[$pow]; } } + +if (! function_exists('csp_nonce')) { + /** + * Generate a CSP nonce for inline scripts + * This should be stored in the request and reused across the request lifecycle + */ + function csp_nonce(): string + { + static $nonce = null; + + if ($nonce === null) { + $nonce = base64_encode(random_bytes(16)); + } + + return $nonce; + } +} diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index 6bd422acb..15fba19aa 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -37,6 +37,12 @@ class ForgotPasswordController extends Controller */ public function showLinkRequestForm(Request $request) { + // If it's a GET request, just show the form + if ($request->isMethod('get')) { + return view('auth.passwords.email'); + } + + // Handle POST request $sent = ''; $email = $request->input('email') ?? ''; $rssToken = $request->input('apikey') ?? ''; @@ -45,10 +51,8 @@ class ForgotPasswordController extends Controller return view('auth.passwords.email')->withErrors(['error' => 'Missing parameter (email and/or apikey) to send password reset']); } - if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) { - $validate = Validator::make($request->all(), [ - 'g-recaptcha-response' => 'required|captcha', - ]); + if (\App\Support\CaptchaHelper::isEnabled()) { + $validate = Validator::make($request->all(), \App\Support\CaptchaHelper::getValidationRules()); if ($validate->fails()) { return view('auth.passwords.email')->withErrors(['error' => 'Captcha validation failed.']); } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index dab2b8790..4ec6c7e92 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -13,6 +13,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; class LoginController extends Controller @@ -73,7 +74,13 @@ class LoginController extends Controller } if ($validator->passes()) { - $user = User::query()->orWhere(['username' => $request->input('username'), 'email' => $request->input('username')])->withTrashed()->first(); + $user = User::query() + ->where(function ($query) use ($request) { + $query->where('username', $request->input('username')) + ->orWhere('email', $request->input('username')); + }) + ->withTrashed() + ->first(); if ($user !== null) { // Check if user is soft deleted if ($user->trashed()) { @@ -147,9 +154,11 @@ class LoginController extends Controller } $this->incrementLoginAttempts($request); + Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); $request->session()->flash('message', 'Username or email and password combination used does not match our records!'); } else { $this->incrementLoginAttempts($request); + Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); $request->session()->flash('message', 'Username or email used do not match our records!'); } @@ -158,6 +167,7 @@ class LoginController extends Controller $this->incrementLoginAttempts($request); $request->session()->flash('message', implode('', Arr::collapse($validator->errors()->toArray()))); + Log::channel('failed_login')->error('Failed login attempt by user: '.$request->input('username').' from IP address: '.$request->ip()); return redirect()->to('login'); } diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index e03c7fe8c..b0e805cb7 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -11,7 +11,6 @@ use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Redirector; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Password as PasswordFacade; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rules\Password; @@ -130,12 +129,19 @@ class RegisterController extends Controller 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable'], '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()) { - $error = implode('', Arr::collapse($validator->errors()->toArray())); - - return $this->showRegistrationForm($request, $error); + return redirect()->back() + ->withErrors($validator) + ->withInput($request->except('password', 'password_confirmation')); } $action = $request->input('action') ?? 'view'; @@ -164,7 +170,7 @@ class RegisterController extends Controller // Check invitation validity using custom system $invitationValid = $this->isInvitationValid($inviteCode, $email); - $registrationOpen = Settings::settingValue('registerstatus') !== Settings::REGISTER_STATUS_INVITE; + $registrationOpen = Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_OPEN; if ($invitationValid || $registrationOpen) { // Get invited_by from invitation if available @@ -229,12 +235,10 @@ class RegisterController extends Controller break; case 'view': - // See if it is a valid invite. + // Don't set showRegister here - let showRegistrationForm handle it + // Only validate invite code if present if (($inviteCode !== null) && ! $this->isInvitationTokenValid($inviteCode)) { $error = 'Invalid invitation token!'; - $showRegister = 0; - } else { - $showRegister = 1; } break; } diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php new file mode 100644 index 000000000..2f89a9e2c --- /dev/null +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -0,0 +1,70 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return $response; + } + + // Generate nonce for inline scripts + $nonce = function_exists('csp_nonce') ? csp_nonce() : base64_encode(random_bytes(16)); + + // Build CSP directives for non-Turnstile pages + $directives = [ + "default-src 'self'", + "script-src 'self' 'nonce-{$nonce}' 'unsafe-inline' 'unsafe-eval' https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://unpkg.com blob:", + "script-src-elem 'self' 'nonce-{$nonce}' 'unsafe-inline' https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://unpkg.com", + "script-src-attr 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com data:", + "img-src 'self' data: https: blob:", + "connect-src 'self' https://www.google.com", + "frame-src 'self' https://www.google.com https://www.gstatic.com data: blob:", + "child-src 'self' https://www.google.com blob:", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + ]; + + $csp = implode('; ', $directives); + + $response->headers->set('Content-Security-Policy', $csp); + + // Also set X-Frame-Options for older browsers + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + + // Set X-Content-Type-Options + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Set Referrer-Policy + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return $response; + } +} diff --git a/app/Models/Settings.php b/app/Models/Settings.php index 31411e0c9..4df00ebe4 100644 --- a/app/Models/Settings.php +++ b/app/Models/Settings.php @@ -27,18 +27,10 @@ use Illuminate\Database\Eloquent\Model; /** * Settings - model for settings table. * - * @property string $section - * @property string $subsection * @property string $name * @property string $value - * @property string $hint - * @property string $setting * - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereHint($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereName($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereSection($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereSetting($value) - * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereSubsection($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Settings whereValue($value) * * @mixin \Eloquent @@ -67,26 +59,19 @@ class Settings extends Model public const ERR_BADTMPUNRARPATH = -6; - public const ERR_BADNZBPATH_UNREADABLE = -7; - - public const ERR_BADNZBPATH_UNSET = -8; - - public const ERR_BAD_COVERS_PATH = -9; - - public const ERR_BAD_YYDECODER_PATH = -10; - public const ERR_BADLAMEPATH = -11; public const ERR_SABCOMPLETEPATH = -12; /** - * @var array + * @var string */ - protected $primaryKey = ['section', 'subsection', 'name']; + protected $primaryKey = 'name'; /** * @var string */ + protected $keyType = 'string'; /** * @var bool @@ -110,6 +95,16 @@ class Settings extends Model protected static $settingsCollection; + /** + * Get the value attribute and convert empty strings to null and numeric strings to numbers. + * + * @param string $value + */ + public function getValueAttribute($value): mixed + { + return self::convertValue($value); + } + /** * Adapted from https://laravel.io/forum/01-15-2016-overriding-eloquent-attributes. * @@ -130,41 +125,60 @@ class Settings extends Model } /** - * Return a tree-like array of all or selected settings. - * - * @param bool $excludeUnsectioned If rows with empty 'section' field should be excluded. - * Note this doesn't prevent empty 'subsection' fields. + * Return a simple key-value array of all settings. * * @throws \RuntimeException */ public static function toTree(bool $excludeUnsectioned = true): array { - $results = self::cursor()->remember(); + $results = self::query()->pluck('value', 'name')->toArray(); - $tree = []; - if (! empty($results)) { - foreach ($results as $result) { - if (! $excludeUnsectioned || ! empty($result->section)) { - $tree[$result->section][$result->subsection][$result->name] = - ['value' => $result->value, 'hint' => $result->hint]; - } - } - } else { + if (empty($results)) { throw new \RuntimeException( 'No results from Settings table! Check your table has been created and populated.' ); } - return $tree; + return $results; } public static function settingValue($setting): mixed { - return self::query()->where( - [ - 'name' => $setting, - ] - )->value('value'); + $value = self::query()->where('name', $setting)->value('value'); + + // Apply the same conversion logic as the accessor + return self::convertValue($value); + } + + /** + * Convert setting value: numeric strings to numbers, preserve empty strings. + * + * @param string|null $value + */ + public static function convertValue($value): mixed + { + // Handle null + if ($value === null) { + return null; + } + + // Keep empty strings as empty strings (don't convert to null) + // Many settings expect empty strings, not null + if ($value === '') { + return ''; + } + + // Convert numeric strings to actual numbers + if (is_numeric($value)) { + // Check if it's an integer or float + if (strpos($value, '.') !== false) { + return (float) $value; + } + + return (int) $value; + } + + return $value; } public static function settingsUpdate(array $data = []): void diff --git a/app/Models/User.php b/app/Models/User.php index 2d403c704..24f05953c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -690,4 +690,10 @@ class User extends Authenticatable { return $this->hasOne(PasswordSecurity::class); } + + public static function canPost($user_id): bool + { + // return true if can_post column is true and false if can_post column is false + return self::where('id', $user_id)->value('can_post'); + } } diff --git a/app/Services/TurnstileService.php b/app/Services/TurnstileService.php new file mode 100644 index 000000000..0d28c8db9 --- /dev/null +++ b/app/Services/TurnstileService.php @@ -0,0 +1,72 @@ +post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $remoteIp ?? request()->ip(), + ]); + + $result = $response->json(); + + return isset($result['success']) && $result['success'] === true; + } catch (\Exception $e) { + \Log::error('Turnstile verification failed: '.$e->getMessage()); + + return false; + } + } + + /** + * Get the Turnstile HTML widget + */ + public static function display(array $attributes = []): string + { + $sitekey = config('captcha.turnstile.sitekey'); + + if (empty($sitekey)) { + return ''; + } + + $defaultAttributes = [ + 'class' => 'cf-turnstile', + 'data-sitekey' => $sitekey, + 'data-theme' => 'auto', + 'data-size' => 'normal', + ]; + + $attributes = array_merge($defaultAttributes, $attributes); + + $attributesString = ''; + foreach ($attributes as $key => $value) { + $attributesString .= sprintf('%s="%s" ', $key, htmlspecialchars($value)); + } + + return sprintf('
', trim($attributesString)); + } + + /** + * Get the Turnstile JavaScript + */ + public static function renderJs(): string + { + return ''; + } +} diff --git a/app/Support/CaptchaHelper.php b/app/Support/CaptchaHelper.php new file mode 100644 index 000000000..97779d4c8 --- /dev/null +++ b/app/Support/CaptchaHelper.php @@ -0,0 +1,129 @@ +display($attributes); + } + + /** + * Render the captcha JavaScript + */ + public static function renderJs(): string + { + if (! self::isEnabled()) { + return ''; + } + + $provider = self::getProvider(); + + if ($provider === 'turnstile') { + return TurnstileService::renderJs(); + } + + // Default to reCAPTCHA + return app('captcha')->renderJs(); + } + + /** + * Get the captcha response field name + */ + public static function getResponseFieldName(): string + { + $provider = self::getProvider(); + + if ($provider === 'turnstile') { + return 'cf-turnstile-response'; + } + + return 'g-recaptcha-response'; + } + + /** + * Get validation rules for captcha + */ + public static function getValidationRules(): array + { + if (! self::isEnabled()) { + return []; + } + + $provider = self::getProvider(); + $fieldName = self::getResponseFieldName(); + + if ($provider === 'turnstile') { + return [ + $fieldName => [ + 'required', + new \App\Rules\TurnstileRule, + ], + ]; + } + + // Default to reCAPTCHA + return [ + $fieldName => [ + 'required', + 'captcha', + ], + ]; + } +} diff --git a/config/captcha.php b/config/captcha.php index b8cdc943a..9da6e618e 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -1,6 +1,24 @@ env('CAPTCHA_PROVIDER', 'recaptcha'), + + // Google reCAPTCHA settings + 'recaptcha' => [ + 'secret' => env('NOCAPTCHA_SECRET', ''), + 'sitekey' => env('NOCAPTCHA_SITEKEY', ''), + 'enabled' => env('NOCAPTCHA_ENABLED', false), + ], + + // Cloudflare Turnstile settings + 'turnstile' => [ + 'secret' => env('TURNSTILE_SECRET', ''), + 'sitekey' => env('TURNSTILE_SITEKEY', ''), + 'enabled' => env('TURNSTILE_ENABLED', false), + ], + + // Legacy support - keep for backward compatibility 'secret' => env('NOCAPTCHA_SECRET', ''), 'sitekey' => env('NOCAPTCHA_SITEKEY', ''), 'enabled' => env('NOCAPTCHA_ENABLED', false), diff --git a/config/database.php b/config/database.php index ffd63705b..a49542e2d 100644 --- a/config/database.php +++ b/config/database.php @@ -14,8 +14,8 @@ return [ 'username' => env('DB_USERNAME', 'nntmux'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8', - 'collation' => 'utf8_unicode_ci', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', 'strict' => false, 'engine' => 'InnoDB ROW_FORMAT=DYNAMIC', 'options' => extension_loaded('pdo_mysql') ? array_filter([ @@ -56,8 +56,8 @@ return [ 'username' => env('DB_USERNAME', 'nntmux'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8', - 'collation' => 'utf8_unicode_ci', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', 'strict' => false, 'engine' => 'InnoDB ROW_FORMAT=DYNAMIC', 'options' => extension_loaded('pdo_mysql') ? array_filter([ diff --git a/database/migrations/2025_10_20_132950_convert_content_table_to_utf8mb4.php b/database/migrations/2025_10_20_132950_convert_content_table_to_utf8mb4.php new file mode 100644 index 000000000..497a89ec8 --- /dev/null +++ b/database/migrations/2025_10_20_132950_convert_content_table_to_utf8mb4.php @@ -0,0 +1,25 @@ + + ++ ${escapeHtml(message)} +
+ +Loading file list...
+No files available
'; + return; + } + + let html = 'Total Files: ${data.total}
+| File Name | +Size | +
|---|---|
| ${escapeHtml(fileName)} | +${fileSize} | +
${escapeHtml(error.message)}
+Loading media information...
+No media information available
'; + return; + } + + let html = '${escapeHtml(data.subs)}
+${escapeHtml(error.message)}
+