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:
co-authored by
Claude Opus 5.5
parent
823af4a56a
commit
e888a6ed5d
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user