feat: Turnstile captcha, menu khách + nút tắt topbar cấu hình từ admin, ẩn setting nhạy cảm

- Turnstile (App\Support\Turnstile, middleware turnstile) cho login admin và login/register/forgot/reset của khách; cấu hình ở setting key=turnstile, secret mã hoá SignRSA.
- Menu sidebar khách (setting client_menu) và nút tắt topbar (setting client_quick_links) do admin sửa qua administrator/client-menu[/quick-links], khách đọc v1/setting/menu, v1/setting/quick-links.
- v1/setting và settings/public chỉ trả whitelist PublicSetting::FIELDS (trước đây lộ bot_token, inet_api_key).
- Cài đặt tài khoản khách (preferences), rate limit theo IP cho register/resend-verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-12 15:53:43 +07:00
co-authored by Claude Opus 5.5
parent 823af4a56a
commit e888a6ed5d
33 changed files with 1022 additions and 52 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Setting;
final class ClientMenu
{
public const SETTING_KEY = 'client_menu';
public const HREF_PATTERN = '#^(/(?!/)|https?://)#i';
public const ICON_PATTERN = '/^[a-z0-9-]+:[a-z0-9-]+$/i';
/**
* @return list<array{title: string, icon: ?string, href: ?string, disabled: bool, children: list<array{title: string, icon: ?string, href: string, disabled: bool}>}>
*/
public static function items(): array
{
$value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'items' => 'present|array|max:30',
'items.*' => 'array',
'items.*.title' => 'required|string|max:60',
'items.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . self::ICON_PATTERN],
'items.*.href' => ['nullable', 'required_without:items.*.children', 'string', 'max:255', 'regex:' . self::HREF_PATTERN],
'items.*.disabled' => 'sometimes|boolean',
'items.*.children' => 'sometimes|array|max:20',
'items.*.children.*.title' => 'required|string|max:60',
'items.*.children.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . self::ICON_PATTERN],
'items.*.children.*.href' => ['required', 'string', 'max:255', 'regex:' . self::HREF_PATTERN],
'items.*.children.*.disabled' => 'sometimes|boolean',
];
}
/**
* @param array<int, array<string, mixed>> $items
*/
public static function save(array $items): array
{
$normalized = array_map(fn (array $item) => [
...self::normalizeLink($item),
'children' => array_map(self::normalizeLink(...), array_values($item['children'] ?? [])),
], array_values($items));
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized, JSON_UNESCAPED_UNICODE)]);
return $normalized;
}
/**
* @param array<string, mixed> $item
* @return array{title: string, icon: ?string, href: ?string, disabled: bool}
*/
public static function normalizeLink(array $item): array
{
return [
'title' => trim((string) $item['title']),
'icon' => ($item['icon'] ?? '') !== '' ? (string) $item['icon'] : null,
'href' => ($item['href'] ?? '') !== '' ? (string) $item['href'] : null,
'disabled' => (bool) ($item['disabled'] ?? false),
];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Setting;
final class ClientQuickLinks
{
public const SETTING_KEY = 'client_quick_links';
/**
* @return list<array{title: string, icon: ?string, href: string, disabled: bool}>
*/
public static function items(): array
{
$value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'items' => 'present|array|max:6',
'items.*' => 'array',
'items.*.title' => 'required|string|max:40',
'items.*.icon' => ['nullable', 'string', 'max:100', 'regex:' . ClientMenu::ICON_PATTERN],
'items.*.href' => ['required', 'string', 'max:255', 'regex:' . ClientMenu::HREF_PATTERN],
'items.*.disabled' => 'sometimes|boolean',
];
}
/**
* @param array<int, array<string, mixed>> $items
*/
public static function save(array $items): array
{
$normalized = array_map(ClientMenu::normalizeLink(...), array_values($items));
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized, JSON_UNESCAPED_UNICODE)]);
return $normalized;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support;
use App\Models\Setting;
final class PublicSetting
{
public const FIELDS = ['title', 'company', 'logo', 'favi_icon', 'brand_color'];
/**
* @return array<string, string>
*/
public static function values(): array
{
$value = json_decode((string) Setting::where('key', 'setting')->value('value'), true) ?: [];
return array_map(fn (string $field) => (string) ($value[$field] ?? ''), array_combine(self::FIELDS, self::FIELDS));
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Http\Facade\SignRSA;
use App\Models\Setting;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Throwable;
final class Turnstile
{
public const SETTING_KEY = 'turnstile';
public const TOKEN_FIELD = 'cf-turnstile-response';
public const SECRET_MASK = '********';
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
/**
* @return array{enabled: bool, site_key: string, secret_key: string}
*/
public static function stored(): array
{
$value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true) ?: [];
return [
'enabled' => (bool) ($value['enabled'] ?? false),
'site_key' => (string) ($value['site_key'] ?? ''),
'secret_key' => (string) ($value['secret_key'] ?? ''),
];
}
public static function siteKey(): ?string
{
$config = self::stored();
return $config['enabled'] && $config['site_key'] !== '' && $config['secret_key'] !== ''
? $config['site_key']
: null;
}
public static function verify(?string $token, ?string $ip): bool
{
if (self::siteKey() === null) {
return true;
}
if ($token === null || $token === '' || strlen($token) > 2048) {
return false;
}
try {
$response = Http::asForm()->timeout(10)->post(self::VERIFY_URL, array_filter([
'secret' => SignRSA::DecryptFunc(self::stored()['secret_key']),
'response' => $token,
'remoteip' => $ip,
]));
} catch (Throwable $e) {
Log::warning('Turnstile siteverify failed', ['error' => $e->getMessage()]);
return false;
}
if ($response->json('success') !== true) {
Log::info('Turnstile rejected', ['codes' => $response->json('error-codes')]);
return false;
}
return true;
}
/**
* @param array<string, mixed> $input
*/
public static function save(array $input): void
{
$current = self::stored();
$secret = (string) ($input['secret_key'] ?? self::SECRET_MASK);
$config = [
'enabled' => (bool) ($input['enabled'] ?? $current['enabled']),
'site_key' => trim((string) ($input['site_key'] ?? $current['site_key'])),
'secret_key' => match (true) {
$secret === self::SECRET_MASK => $current['secret_key'],
trim($secret) === '' => '',
default => SignRSA::EncryptFunc(trim($secret)),
},
];
if ($config['enabled'] && ($config['site_key'] === '' || $config['secret_key'] === '')) {
throw new InvalidArgumentException('Cần nhập Site key và Secret key trước khi bật Turnstile.');
}
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($config)]);
}
/**
* @return array{enabled: bool, site_key: string, secret_key: string}
*/
public static function forAdmin(): array
{
$config = self::stored();
$config['secret_key'] = $config['secret_key'] === '' ? '' : self::SECRET_MASK;
return $config;
}
}