feat(admin): manage captcha configuration centrally

- add a dedicated Filament settings tab for captcha drivers
- persist selections into config-compatible schema and migrate legacy keys
- extend captcha manager to consume database overrides transparently

Signed-off-by: Qi HU <github@spcsky.com>
This commit is contained in:
Qi HU
2025-10-13 12:15:33 +08:00
parent 60e1e45d73
commit ce913b7a54
5 changed files with 274 additions and 0 deletions
@@ -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 = [
+10
View File
@@ -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);
+42
View File
@@ -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',
+42
View File
@@ -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',
+42
View File
@@ -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',