diff --git a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php index c2068ebc..97ca00b6 100644 --- a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php +++ b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php @@ -11,7 +11,9 @@ use Filament\Forms\Components\Radio; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\CheckboxList; +use Filament\Schemas\Components\Fieldset; use Filament\Forms\Components\Repeater; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Section; use App\Auth\Permission; use App\Filament\OptionsTrait; @@ -101,6 +103,11 @@ class EditSetting extends Page implements HasForms } } Setting::query()->upsert($data, ['name'], ['value']); + Setting::query()->whereIn('name', [ + 'captcha.driver', + 'captcha.turnstile', + 'captcha.recaptcha', + ])->delete(); $this->doAfterUpdate(); do_action("nexus_setting_update"); clear_setting_cache(); @@ -178,6 +185,12 @@ class EditSetting extends Page implements HasForms ->columns(2) ; + $tabs[] = Tab::make(__('label.setting.captcha.tab_header')) + ->id('captcha') + ->schema($this->getTabCaptchaSchema()) + ->columns(2) + ; + $tabs[] = Tab::make(__('label.setting.system.tab_header')) ->id('system') ->schema([ @@ -236,6 +249,131 @@ class EditSetting extends Page implements HasForms return $tabs; } + private function getTabCaptchaSchema(): array + { + $captchaPrefix = 'captcha'; + + $driverOptions = [ + 'image' => __('label.setting.captcha.drivers.image'), + 'cloudflare_turnstile' => __('label.setting.captcha.drivers.cloudflare_turnstile'), + 'google_recaptcha_v2' => __('label.setting.captcha.drivers.google_recaptcha_v2'), + ]; + + $defaultDriver = Setting::get('captcha.default'); + if (is_null($defaultDriver)) { + $defaultDriver = Setting::get('captcha.driver', nexus_env('CAPTCHA_DRIVER', 'image')); + } + + $turnstileSiteKey = Setting::get( + 'captcha.drivers.cloudflare_turnstile.site_key', + Setting::get('captcha.turnstile.site_key', nexus_env('TURNSTILE_SITE_KEY')) + ); + $turnstileSecretKey = Setting::get( + 'captcha.drivers.cloudflare_turnstile.secret_key', + Setting::get('captcha.turnstile.secret_key', nexus_env('TURNSTILE_SECRET_KEY')) + ); + $turnstileTheme = Setting::get( + 'captcha.drivers.cloudflare_turnstile.theme', + Setting::get('captcha.turnstile.theme', nexus_env('TURNSTILE_THEME', 'auto')) + ); + $turnstileSize = Setting::get( + 'captcha.drivers.cloudflare_turnstile.size', + Setting::get('captcha.turnstile.size', nexus_env('TURNSTILE_SIZE', 'flexible')) + ); + + $recaptchaSiteKey = Setting::get( + 'captcha.drivers.google_recaptcha_v2.site_key', + Setting::get('captcha.recaptcha.site_key', nexus_env('RECAPTCHA_SITE_KEY')) + ); + $recaptchaSecretKey = Setting::get( + 'captcha.drivers.google_recaptcha_v2.secret_key', + Setting::get('captcha.recaptcha.secret_key', nexus_env('RECAPTCHA_SECRET_KEY')) + ); + $recaptchaTheme = Setting::get( + 'captcha.drivers.google_recaptcha_v2.theme', + Setting::get('captcha.recaptcha.theme', nexus_env('RECAPTCHA_THEME', 'light')) + ); + $recaptchaSize = Setting::get( + 'captcha.drivers.google_recaptcha_v2.size', + Setting::get('captcha.recaptcha.size', nexus_env('RECAPTCHA_SIZE', 'normal')) + ); + + $schema = [ + Select::make("$captchaPrefix.default") + ->options($driverOptions) + ->label(__('label.setting.captcha.driver')) + ->helperText(__('label.setting.captcha.driver_help')) + ->default($defaultDriver) + ->reactive() + , + Fieldset::make(__('label.setting.captcha.turnstile.section')) + ->visible(fn (Get $get) => $get('captcha.default') === 'cloudflare_turnstile') + ->schema([ + TextInput::make('captcha.drivers.cloudflare_turnstile.site_key') + ->label(__('label.setting.captcha.turnstile.site_key')) + ->helperText(__('label.setting.captcha.turnstile.site_key_help')) + ->default($turnstileSiteKey) , + TextInput::make('captcha.drivers.cloudflare_turnstile.secret_key') + ->label(__('label.setting.captcha.turnstile.secret_key')) + ->helperText(__('label.setting.captcha.turnstile.secret_key_help')) + ->password() + ->revealable() + ->default($turnstileSecretKey), + Select::make('captcha.drivers.cloudflare_turnstile.theme') + ->label(__('label.setting.captcha.turnstile.theme')) + ->helperText(__('label.setting.captcha.turnstile.theme_help')) + ->options([ + 'auto' => __('label.setting.captcha.turnstile.theme_auto'), + 'light' => __('label.setting.captcha.turnstile.theme_light'), + 'dark' => __('label.setting.captcha.turnstile.theme_dark'), + ]) + ->default($turnstileTheme), + Select::make('captcha.drivers.cloudflare_turnstile.size') + ->label(__('label.setting.captcha.turnstile.size')) + ->helperText(__('label.setting.captcha.turnstile.size_help')) + ->options([ + 'normal' => __('label.setting.captcha.turnstile.size_normal'), + 'compact' => __('label.setting.captcha.turnstile.size_compact'), + 'flexible' => __('label.setting.captcha.turnstile.size_flexible'), + ]) + ->default($turnstileSize), + ]) + , + Fieldset::make(__('label.setting.captcha.recaptcha.section')) + ->visible(fn (Get $get) => $get('captcha.default') === 'google_recaptcha_v2') + ->schema([ + TextInput::make('captcha.drivers.google_recaptcha_v2.site_key') + ->label(__('label.setting.captcha.recaptcha.site_key')) + ->helperText(__('label.setting.captcha.recaptcha.site_key_help')) + ->default($recaptchaSiteKey), + TextInput::make('captcha.drivers.google_recaptcha_v2.secret_key') + ->label(__('label.setting.captcha.recaptcha.secret_key')) + ->helperText(__('label.setting.captcha.recaptcha.secret_key_help')) + ->password() + ->revealable() + ->default($recaptchaSecretKey), + Select::make('captcha.drivers.google_recaptcha_v2.theme') + ->label(__('label.setting.captcha.recaptcha.theme')) + ->helperText(__('label.setting.captcha.recaptcha.theme_help')) + ->options([ + 'light' => __('label.setting.captcha.recaptcha.theme_light'), + 'dark' => __('label.setting.captcha.recaptcha.theme_dark'), + ]) + ->default($recaptchaTheme), + Select::make('captcha.drivers.google_recaptcha_v2.size') + ->label(__('label.setting.captcha.recaptcha.size')) + ->helperText(__('label.setting.captcha.recaptcha.size_help')) + ->options([ + 'normal' => __('label.setting.captcha.recaptcha.size_normal'), + 'compact' => __('label.setting.captcha.recaptcha.size_compact'), + ]) + ->default($recaptchaSize), + ]) + ]; + + return $schema; + } + private function getHitAndRunSchema() { $default = [ diff --git a/app/Services/Captcha/CaptchaManager.php b/app/Services/Captcha/CaptchaManager.php index f4f47369..183e3df4 100644 --- a/app/Services/Captcha/CaptchaManager.php +++ b/app/Services/Captcha/CaptchaManager.php @@ -3,6 +3,7 @@ namespace App\Services\Captcha; use App\Services\Captcha\Exceptions\CaptchaValidationException; +use App\Models\Setting; use Illuminate\Support\Arr; class CaptchaManager @@ -113,6 +114,15 @@ class CaptchaManager } $this->config = is_array($config) ? $config : []; + + try { + $settings = Setting::get('captcha', []); + if (is_array($settings) && !empty($settings)) { + $this->config = array_replace_recursive($this->config, $settings); + } + } catch (\Throwable $exception) { + // ignore database errors at bootstrap phase + } } return Arr::get($this->config, $key, $default); diff --git a/resources/lang/en/label.php b/resources/lang/en/label.php index a4cca958..7d5f42e7 100644 --- a/resources/lang/en/label.php +++ b/resources/lang/en/label.php @@ -103,6 +103,48 @@ return [ 'max_uploaded_duration' => 'Maximum upload volume multiplier effective time range', 'max_uploaded_duration_help' => 'Unit: hours. The maximum upload volume multiplier takes effect within this time range after the torrent is published, and does not take effect beyond this range. A setting of 0 is always in effect', ], + 'captcha' => [ + 'tab_header' => 'Captcha', + 'driver' => 'Captcha driver', + 'driver_help' => 'Choose which verification mechanism is displayed on public forms.', + 'drivers' => [ + 'image' => 'Built-in image captcha', + 'cloudflare_turnstile' => 'Cloudflare Turnstile', + 'google_recaptcha_v2' => 'Google reCAPTCHA v2', + ], + 'turnstile' => [ + 'section' => 'Cloudflare Turnstile', + 'site_key' => 'Site key', + 'site_key_help' => 'Copied from the Cloudflare Turnstile dashboard.', + 'secret_key' => 'Secret key', + 'secret_key_help' => 'Keep this value private.', + 'theme' => 'Theme', + 'theme_help' => 'Automatically adapts when set to Auto.', + 'theme_auto' => 'Auto', + 'theme_light' => 'Light', + 'theme_dark' => 'Dark', + 'size' => 'Widget size', + 'size_help' => 'Flexible stretches to match the container width.', + 'size_normal' => 'Normal', + 'size_compact' => 'Compact', + 'size_flexible' => 'Flexible', + ], + 'recaptcha' => [ + 'section' => 'Google reCAPTCHA v2', + 'site_key' => 'Site key', + 'site_key_help' => 'Provided by the Google reCAPTCHA admin console.', + 'secret_key' => 'Secret key', + 'secret_key_help' => 'Keep this value private.', + 'theme' => 'Theme', + 'theme_help' => 'Use dark when your site runs a dark palette.', + 'theme_light' => 'Light', + 'theme_dark' => 'Dark', + 'size' => 'Widget size', + 'size_help' => 'Compact is suitable for narrow layouts.', + 'size_normal' => 'Normal', + 'size_compact' => 'Compact', + ], + ], 'meilisearch' => [ 'tab_header' => 'Meilisearch', 'enabled' => 'Whether to enable Meilisearch', diff --git a/resources/lang/zh_CN/label.php b/resources/lang/zh_CN/label.php index 025cc46f..c2843fba 100644 --- a/resources/lang/zh_CN/label.php +++ b/resources/lang/zh_CN/label.php @@ -144,6 +144,48 @@ return [ 'max_uploaded_duration' => '最大上传量倍数有效时间范围', 'max_uploaded_duration_help' => '单位:小时。种子发布后的这个时间范围内,最大上传量倍数生效,超过此范围不生效。设置为 0 一直生效', ], + 'captcha' => [ + 'tab_header' => '验证码', + 'driver' => '验证码驱动', + 'driver_help' => '选择在登录等公开页面展示的验证码方案。', + 'drivers' => [ + 'image' => '内置图片验证码', + 'cloudflare_turnstile' => 'Cloudflare Turnstile', + 'google_recaptcha_v2' => 'Google reCAPTCHA v2', + ], + 'turnstile' => [ + 'section' => 'Cloudflare Turnstile 配置', + 'site_key' => '站点密钥', + 'site_key_help' => '在 Cloudflare Turnstile 后台获取。', + 'secret_key' => '私钥', + 'secret_key_help' => '请妥善保管,不要泄露。', + 'theme' => '主题', + 'theme_help' => '选择“自动”时会根据页面自动适配。', + 'theme_auto' => '自动', + 'theme_light' => '浅色', + 'theme_dark' => '深色', + 'size' => '组件尺寸', + 'size_help' => '弹性模式会根据容器宽度自动拉伸。', + 'size_normal' => '普通', + 'size_compact' => '紧凑', + 'size_flexible' => '弹性', + ], + 'recaptcha' => [ + 'section' => 'Google reCAPTCHA v2 配置', + 'site_key' => '站点密钥', + 'site_key_help' => '在 Google reCAPTCHA 控制台获取。', + 'secret_key' => '私钥', + 'secret_key_help' => '请妥善保管,不要泄露。', + 'theme' => '主题', + 'theme_help' => '深色主题适用于深色背景。', + 'theme_light' => '浅色', + 'theme_dark' => '深色', + 'size' => '组件尺寸', + 'size_help' => '紧凑模式适合较窄的布局。', + 'size_normal' => '普通', + 'size_compact' => '紧凑', + ], + ], 'meilisearch' => [ 'tab_header' => 'Meilisearch', 'enabled' => '是否启用 Meilisearch', diff --git a/resources/lang/zh_TW/label.php b/resources/lang/zh_TW/label.php index ea34cf21..210e23dc 100644 --- a/resources/lang/zh_TW/label.php +++ b/resources/lang/zh_TW/label.php @@ -103,6 +103,48 @@ return [ 'max_uploaded_duration' => '最大上傳量倍數有效時間範圍', 'max_uploaded_duration_help' => '單位:小時。種子發布後的這個時間範圍內,最大上傳量倍數生效,超過此範圍不生效。設置為 0 一直生效', ], + 'captcha' => [ + 'tab_header' => '驗證碼', + 'driver' => '驗證碼驅動', + 'driver_help' => '選擇在登入等公開頁面顯示的驗證方案。', + 'drivers' => [ + 'image' => '內建圖片驗證碼', + 'cloudflare_turnstile' => 'Cloudflare Turnstile', + 'google_recaptcha_v2' => 'Google reCAPTCHA v2', + ], + 'turnstile' => [ + 'section' => 'Cloudflare Turnstile 設定', + 'site_key' => '站點金鑰', + 'site_key_help' => '於 Cloudflare Turnstile 後台取得。', + 'secret_key' => '私鑰', + 'secret_key_help' => '請妥善保存,避免外洩。', + 'theme' => '主題', + 'theme_help' => '選擇「自動」時會依頁面自動調整。', + 'theme_auto' => '自動', + 'theme_light' => '淺色', + 'theme_dark' => '深色', + 'size' => '元件尺寸', + 'size_help' => '彈性模式會依容器寬度伸縮。', + 'size_normal' => '一般', + 'size_compact' => '緊湊', + 'size_flexible' => '彈性', + ], + 'recaptcha' => [ + 'section' => 'Google reCAPTCHA v2 設定', + 'site_key' => '站點金鑰', + 'site_key_help' => '於 Google reCAPTCHA 控制台取得。', + 'secret_key' => '私鑰', + 'secret_key_help' => '請妥善保存,避免外洩。', + 'theme' => '主題', + 'theme_help' => '深色主題適用於深色背景。', + 'theme_light' => '淺色', + 'theme_dark' => '深色', + 'size' => '元件尺寸', + 'size_help' => '緊湊模式適合較狹窄的版面。', + 'size_normal' => '一般', + 'size_compact' => '緊湊', + ], + ], 'meilisearch' => [ 'tab_header' => 'Meilisearch', 'enabled' => '是否啟用 Meilisearch',