Compare commits

...

8 Commits

Author SHA1 Message Date
Xboard
76a800ddbb Merge pull request #832 from Dlphine/fix/raw-array-access-data-get
fix: replace raw array access with data_get() to prevent Undefined array key
2026-03-28 17:38:44 +08:00
xboard
bbc96a18bc fix: use getHost() for proper host comparison in safe mode 2026-03-28 15:52:25 +08:00
xboard
23294c1f93 fix: escape Telegram Markdown special characters (fix #450) 2026-03-28 09:10:54 +08:00
xboard
130f7c82a8 feat: revoke other sessions when changing password (fix #414) 2026-03-28 08:31:24 +08:00
xboard
0ab67c7a9b fix: add ru-RU.json 2026-03-28 07:44:43 +08:00
xboard
5512841ba2 fix: iOS Safari autofill not filling email field (Fixes #330) 2026-03-28 07:41:57 +08:00
xboard
7fbd1bb92d feat: implement email case-insensitive queries (fix #318) 2026-03-28 07:09:21 +08:00
Dlphine
5dd4cd4bc9 fix: replace raw array access with data_get() to prevent Undefined array key
- Migrate $protocol_settings['key'] to data_get($protocol_settings, 'key') across General, SingBox, Shadowrocket, Surfboard, QuantumultX
- Prevents PHP 8 Undefined array key fatal errors when optional protocol_settings fields are missing
- Same class of bug that caused #735
2026-03-27 13:51:28 +08:00
19 changed files with 263 additions and 29 deletions

View File

@@ -43,7 +43,7 @@ class ResetPassword extends Command
public function handle()
{
$password = $this->argument('password') ;
$user = User::where('email', $this->argument('email'))->first();
$user = User::byEmail($this->argument('email'))->first();
if (!$user) abort(500, '邮箱不存在');
$password = $password ?? Helper::guid(false);
$user->password = password_hash($password, PASSWORD_DEFAULT);

View File

@@ -29,7 +29,7 @@ class CommController extends Controller
// 检查白名单后缀限制
if ((int) admin_setting('email_whitelist_enable', 0)) {
$isRegisteredEmail = User::where('email', $email)->exists();
$isRegisteredEmail = User::byEmail($email)->exists();
if (!$isRegisteredEmail) {
$allowedSuffixes = Helper::getEmailSuffix();
$emailSuffix = substr(strrchr($email, '@'), 1);

View File

@@ -74,6 +74,14 @@ class UserController extends Controller
if (!$user->save()) {
return $this->fail([400, __('Save failed')]);
}
$currentToken = $user->currentAccessToken();
if ($currentToken) {
$user->tokens()->where('id', '!=', $currentToken->id)->delete();
} else {
$user->tokens()->delete();
}
return $this->success(true);
}

View File

@@ -199,7 +199,7 @@ class OrderController extends Controller
public function assign(OrderAssign $request)
{
$plan = Plan::find($request->input('plan_id'));
$user = User::where('email', $request->input('email'))->first();
$user = User::byEmail($request->input('email'))->first();
if (!$user) {
return $this->fail([400202, '该用户不存在']);

View File

@@ -220,7 +220,7 @@ class UserController extends Controller
return $this->fail([400202, '用户不存在']);
}
if (isset($params['email'])) {
if (User::where('email', $params['email'])->first() && $user->email !== $params['email']) {
if (User::byEmail($params['email'])->first() && $user->email !== $params['email']) {
return $this->fail([400201, '邮箱已被使用']);
}
}
@@ -240,7 +240,7 @@ class UserController extends Controller
$params['group_id'] = $plan->group_id;
}
// 处理邀请用户
if ($request->input('invite_user_email') && $inviteUser = User::where('email', $request->input('invite_user_email'))->first()) {
if ($request->input('invite_user_email') && $inviteUser = User::byEmail($request->input('invite_user_email'))->first()) {
$params['invite_user_id'] = $inviteUser->id;
} else {
$params['invite_user_id'] = null;
@@ -365,7 +365,7 @@ class UserController extends Controller
if ($request->input('email_prefix')) {
$email = $request->input('email_prefix') . '@' . $request->input('email_suffix');
if (User::where('email', $email)->exists()) {
if (User::byEmail($email)->exists()) {
return $this->fail([400201, '邮箱已存在于系统中']);
}

View File

@@ -3,6 +3,8 @@
namespace App\Models;
use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -81,6 +83,20 @@ class User extends Authenticatable
public const COMMISSION_TYPE_SYSTEM = 0;
public const COMMISSION_TYPE_PERIOD = 1;
public const COMMISSION_TYPE_ONETIME = 2;
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
/**
* 按邮箱查询(大小写不敏感,兼容所有数据库)
*/
public function scopeByEmail(Builder $query, string $email): Builder
{
return $query->where('email', strtolower(trim($email)));
}
// 获取邀请人信息
public function invite_user(): BelongsTo

View File

@@ -61,7 +61,7 @@ class General extends AbstractProtocol
$str = str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode("{$protocol_settings['cipher']}:{$password}")
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
);
$addr = Helper::wrapIPv6($server['host']);
$plugin = data_get($protocol_settings, 'plugin');
@@ -84,11 +84,11 @@ class General extends AbstractProtocol
"port" => (string) $server['port'],
"id" => $uuid,
"aid" => '0',
"net" => $server['protocol_settings']['network'],
"net" => data_get($server, 'protocol_settings.network'),
"type" => "none",
"host" => "",
"path" => "",
"tls" => $protocol_settings['tls'] ? "tls" : "",
"tls" => data_get($protocol_settings, 'tls') ? "tls" : "",
];
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$config['sni'] = $serverName;
@@ -97,7 +97,7 @@ class General extends AbstractProtocol
$config['fp'] = $fp;
}
switch ($protocol_settings['network']) {
switch (data_get($protocol_settings, 'network')) {
case 'tcp':
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'http');
@@ -152,11 +152,11 @@ class General extends AbstractProtocol
'mode' => 'multi', //grpc传输模式
'security' => '', //传输层安全 tls/reality
'encryption' => 'none', //加密方式
'type' => $server['protocol_settings']['network'], //传输协议
'type' => data_get($server, 'protocol_settings.network'), //传输协议
'flow' => data_get($protocol_settings, 'flow'),
];
// 处理TLS
switch ($server['protocol_settings']['tls']) {
switch (data_get($server, 'protocol_settings.tls')) {
case 1:
$config['security'] = "tls";
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
@@ -184,7 +184,7 @@ class General extends AbstractProtocol
break;
}
// 处理传输协议
switch ($server['protocol_settings']['network']) {
switch (data_get($server, 'protocol_settings.network')) {
case 'ws':
if ($path = data_get($protocol_settings, 'network_settings.path'))
$config['path'] = $path;
@@ -256,7 +256,7 @@ class General extends AbstractProtocol
break;
}
switch ($server['protocol_settings']['network']) {
switch (data_get($server, 'protocol_settings.network')) {
case 'ws':
$array['type'] = 'ws';
if ($path = data_get($protocol_settings, 'network_settings.path'))

View File

@@ -46,7 +46,7 @@ class QuantumultX extends AbstractProtocol
$addr = Helper::wrapIPv6($server['host']);
$config = [
"shadowsocks={$addr}:{$server['port']}",
"method={$protocol_settings['cipher']}",
"method=" . data_get($protocol_settings, 'cipher'),
"password={$password}",
];

View File

@@ -76,7 +76,7 @@ class Shadowrocket extends AbstractProtocol
$str = str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode("{$protocol_settings['cipher']}:{$password}")
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
);
$addr = Helper::wrapIPv6($server['host']);
@@ -98,7 +98,7 @@ class Shadowrocket extends AbstractProtocol
'remark' => $server['name'],
'alterId' => 0
];
if ($protocol_settings['tls']) {
if (data_get($protocol_settings, 'tls')) {
$config['tls'] = 1;
if (data_get($protocol_settings, 'tls_settings')) {
if (!!data_get($protocol_settings, 'tls_settings.allow_insecure'))
@@ -352,7 +352,7 @@ class Shadowrocket extends AbstractProtocol
}
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
if (isset($protocol_settings['hop_interval'])) {
$params['keepalive'] = $protocol_settings['hop_interval'];
$params['keepalive'] = data_get($protocol_settings, 'hop_interval');
}
if (isset($server['ports'])) {
$params['mport'] = $server['ports'];

View File

@@ -434,8 +434,8 @@ class SingBox extends AbstractProtocol
$array['flow'] = $flow;
}
if ($protocol_settings['tls']) {
$tlsMode = (int) $protocol_settings['tls'];
if (data_get($protocol_settings, 'tls')) {
$tlsMode = (int) data_get($protocol_settings, 'tls', 0);
$tlsConfig = [
'enabled' => true,
'insecure' => $tlsMode === 2

View File

@@ -89,7 +89,7 @@ class Surfboard extends AbstractProtocol
"{$server['name']}=ss",
"{$server['host']}",
"{$server['port']}",
"encrypt-method={$protocol_settings['cipher']}",
"encrypt-method=" . data_get($protocol_settings, 'cipher'),
"password={$password}",
'tfo=true',
'udp-relay=true'

View File

@@ -36,7 +36,7 @@ class LoginService
}
// 查找用户
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [false, [400, __('Incorrect email or password')]];
}
@@ -99,7 +99,7 @@ class LoginService
}
// 查找用户
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [false, [400, __('This email is not registered in the system')]];
}

View File

@@ -27,7 +27,7 @@ class MailLinkService
return [false, [429, __('Sending frequently, please try again later')]];
}
$user = User::where('email', $email)->first();
$user = User::byEmail($email)->first();
if (!$user) {
return [true, true]; // 成功但用户不存在,保护用户隐私
}

View File

@@ -91,8 +91,7 @@ class RegisterService
}
// 检查邮箱是否存在
$email = $request->input('email');
$exist = User::where('email', $email)->first();
$exist = User::byEmail($request->input('email'))->first();
if ($exist) {
return [false, [400201, __('Email already exists')]];
}

View File

@@ -29,7 +29,9 @@ class TelegramService
public function sendMessage(int $chatId, string $text, string $parseMode = ''): void
{
$text = $parseMode === 'markdown' ? str_replace('_', '\_', $text) : $text;
if ($parseMode === 'markdown') {
$text = $this->escapeMarkdown($text);
}
$this->request('sendMessage', [
'chat_id' => $chatId,
@@ -38,6 +40,26 @@ class TelegramService
]);
}
/**
* 转义 Telegram Markdown 特殊字符
*/
protected function escapeMarkdown(string $text): string
{
$escapeChars = ['_', '*', '`', '['];
$escapedText = '';
for ($i = 0; $i < strlen($text); $i++) {
$char = $text[$i];
if (in_array($char, $escapeChars, true)) {
$escapedText .= '\\' . $char;
} else {
$escapedText .= $char;
}
}
return $escapedText;
}
public function approveChatJoinRequest(int $chatId, int $userId): void
{
$this->request('approveChatJoinRequest', [

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 统计需要转换的记录数
$count = DB::table('v2_user')
->whereNotNull('email')
->whereRaw('email != LOWER(email)')
->count();
if ($count > 0) {
Log::info("Converting {$count} email(s) to lowercase");
DB::table('v2_user')
->whereNotNull('email')
->whereRaw('email != LOWER(email)')
->update(['email' => DB::raw('LOWER(email)')]);
Log::info("Email lowercase conversion completed");
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 无法恢复原始大小写
}
};

148
resources/lang/ru-RU.json Normal file
View File

@@ -0,0 +1,148 @@
{
"Article does not exist": "Статья не существует",
"Cancel failed": "Ошибка отмены",
"Close failed": "Ошибка закрытия",
"Coupon cannot be empty": "Купон не может быть пустым",
"Coupon failed": "Ошибка купона",
"Currency conversion has timed out, please try again later": "Время конвертации валюты истекло, попробуйте позже",
"Email already exists": "Эл. почта уже существует",
"Email suffix is not in the Whitelist": "Суффикс эл. почты не в белом списке",
"Email suffix is not in whitelist": "Суффикс эл. почты не в белом списке",
"Email verification code": "Код подтверждения эл. почты",
"Email verification code cannot be empty": "Код подтверждения эл. почты не может быть пустым",
"Email verification code has been sent, please request again later": "Код подтверждения отправлен, запросите повторно позже",
"Failed to create order": "Не удалось создать заказ",
"Failed to open ticket": "Не удалось открыть тикет",
"Gmail alias is not supported": "Псевдоним Gmail не поддерживается",
"Incorrect email or password": "Неверная эл. почта или пароль",
"Incorrect email verification code": "Неверный код подтверждения эл. почты",
"Insufficient balance": "Недостаточно средств",
"Insufficient commission balance": "Недостаточно комиссии",
"Invalid code is incorrect": "Неверный код",
"Invalid coupon": "Недействительный купон",
"Invalid invitation code": "Недействительный код приглашения",
"Invalid parameter": "Недопустимый параметр",
"Message cannot be empty": "Сообщение не может быть пустым",
"No active subscription. Unable to use our provided Apple ID": "Нет активной подписки. Невозможно использовать предоставленный Apple ID",
"Oops, there's a problem... Please refresh the page and try again later": "Ой, возникла проблема... Обновите страницу и попробуйте позже",
"Order does not exist": "Заказ не существует",
"Order does not exist or has been paid": "Заказ не существует или уже оплачен",
"Payment failed. Please check your credit card information": "Ошибка оплаты. Проверьте данные карты",
"Payment gateway request failed": "Ошибка запроса к платёжному шлюзу",
"Payment method is not available": "Способ оплаты недоступен",
"Please wait for the technical enginneer to reply": "Ожидайте ответа технического специалиста",
"Register failed": "Ошибка регистрации",
"Registration has closed": "Регистрация закрыта",
"Reset failed": "Ошибка сброса",
"Save failed": "Ошибка сохранения",
"Subscription has expired or no active subscription, unable to purchase Data Reset Package": "Подписка истекла или нет активной подписки, невозможно приобрести пакет сброса данных",
"Subscription plan does not exist": "Тарифный план не существует",
"The coupon code cannot be used for this subscription": "Код купона не может быть использован для этой подписки",
"The current required minimum withdrawal commission is :limit": "Текущая минимальная комиссия для вывода: :limit",
"The maximum number of creations has been reached": "Достигнуто максимальное количество созданий",
"The old password is wrong": "Неверный старый пароль",
"The ticket is closed and cannot be replied": "Тикет закрыт, ответ невозможен",
"The user does not exist": "Пользователь не существует",
"There are other unresolved tickets": "Есть другие нерешённые тикеты",
"This coupon has expired": "Этот купон истёк",
"This coupon has not yet started": "Этот купон ещё не начался",
"This coupon is no longer available": "Этот купон больше недоступен",
"This email is not registered in the system": "Эта эл. почта не зарегистрирована в системе",
"This payment cycle cannot be purchased, please choose another cycle": "Этот платёжный цикл нельзя приобрести, выберите другой",
"This subscription cannot be renewed, please change to another subscription": "Эту подписку нельзя продлить, выберите другую",
"This subscription has been sold out, please choose another subscription": "Эта подписка распродана, выберите другую",
"This subscription has expired, please change to another subscription": "Эта подписка истекла, выберите другую",
"Ticket does not exist": "Тикет не существует",
"Ticket reply failed": "Ошибка ответа на тикет",
"Token error": "Ошибка токена",
"Transfer failed": "Ошибка перевода",
"Unsupported withdrawal": "Вывод не поддерживается",
"Unsupported withdrawal method": "Способ вывода не поддерживается",
"Withdrawal account": "Счёт для вывода",
"Withdrawal method": "Способ вывода",
"You can only cancel pending orders": "Можно отменить только ожидающие заказы",
"You have an unpaid or pending order, please try again later or cancel it": "У вас есть неоплаченный или ожидающий заказ, попробуйте позже или отмените его",
"You must have a valid subscription to view content in this area": "Необходима действующая подписка для просмотра контента",
"You must use the invitation code to register": "Для регистрации необходимо использовать код приглашения",
"Your account has been suspended": "Ваш аккаунт приостановлен",
"[Commission Withdrawal Request] This ticket is opened by the system": "[Запрос на вывод комиссии] Тикет создан системой",
"Plan ID cannot be empty": "ID тарифа не может быть пустым",
"Plan cycle cannot be empty": "Цикл тарифа не может быть пустым",
"Wrong plan cycle": "Неверный цикл тарифа",
"Ticket subject cannot be empty": "Тема тикета не может быть пустой",
"Ticket level cannot be empty": "Уровень тикета не может быть пустым",
"Incorrect ticket level format": "Неверный формат уровня тикета",
"The withdrawal method cannot be empty": "Способ вывода не может быть пустым",
"The withdrawal account cannot be empty": "Счёт для вывода не может быть пустым",
"Old password cannot be empty": "Старый пароль не может быть пустым",
"New password cannot be empty": "Новый пароль не может быть пустым",
"Password must be greater than 8 digits": "Пароль должен быть длиннее 8 символов",
"The transfer amount cannot be empty": "Сумма перевода не может быть пустой",
"The transfer amount parameter is wrong": "Неверный параметр суммы перевода",
"Incorrect format of expiration reminder": "Неверный формат напоминания об истечении",
"Incorrect traffic alert format": "Неверный формат оповещения о трафике",
"Email can not be empty": "Эл. почта не может быть пустой",
"Email format is incorrect": "Неверный формат эл. почты",
"Password can not be empty": "Пароль не может быть пустым",
"The traffic usage in :app_name has reached 80%": "Использование трафика в :app_name достигло 80%",
"The service in :app_name is about to expire": "Сервис в :app_name скоро истекает",
"The coupon can only be used :limit_use_with_user per person": "Купон можно использовать только :limit_use_with_user раз на человека",
"The coupon code cannot be used for this period": "Код купона не может быть использован для этого периода",
"Request failed, please try again later": "Ошибка запроса, попробуйте позже",
"Register frequently, please try again after :minute minute": "Регистрация слишком частая, попробуйте через :minute минуту",
"Uh-oh, we've had some problems, we're working on it.": "Ой, у нас возникли проблемы, мы работаем над этим",
"This subscription reset package does not apply to your subscription": "Этот пакет сброса не适用于 вашей подписки",
"Login to :name": "Вход в :name",
"Sending frequently, please try again later": "Отправка слишком частая, попробуйте позже",
"Current product is sold out": "Товар распродан",
"There are too many password errors, please try again after :minute minutes.": "Слишком много ошибок пароля, попробуйте через :minute минут",
"Reset failed, Please try again later": "Ошибка сброса, попробуйте позже",
"Subscribe": "Подписаться",
"User Information": "Информация о пользователе",
"Username": "Имя пользователя",
"Status": "Статус",
"Active": "Активен",
"Inactive": "Неактивен",
"Data Used": "Использовано данных",
"Data Limit": "Лимит данных",
"Expiration Date": "Дата истечения",
"Reset In": "Сброс через",
"Days": "Дней",
"Subscription Link": "Ссылка подписки",
"Copy": "Копировать",
"Copied": "Скопировано",
"QR Code": "QR-код",
"Unlimited": "Без ограничений",
"Device Limit": "Лимит устройств",
"Devices": "Устройства",
"No Limit": "Без лимита",
"First Day of Month": "Первый день месяца",
"Monthly": "Ежемесячно",
"Never": "Никогда",
"First Day of Year": "Первый день года",
"Yearly": "Ежегодно",
"update.local_newer": "Текущая версия новее удалённой, сначала закоммитьте изменения",
"update.already_latest": "Уже установлена последняя версия",
"update.process_running": "Процесс обновления уже запущен",
"update.success": "Обновление успешно, с :from до :to, система перезагрузится автоматически",
"update.failed": "Ошибка обновления: :error",
"update.backup_failed": "Ошибка резервного копирования БД: :error",
"update.code_update_failed": "Ошибка обновления кода: :error",
"update.migration_failed": "Ошибка миграции БД: :error",
"update.cache_clear_failed": "Ошибка очистки кэша: :error",
"update.flag_create_failed": "Не удалось создать флаг обновления: :error",
"traffic_reset.reset_type.monthly": "Ежемесячный сброс",
"traffic_reset.reset_type.first_day_month": "Сброс в первый день месяца",
"traffic_reset.reset_type.yearly": "Ежегодный сброс",
"traffic_reset.reset_type.first_day_year": "Сброс в первый день года",
"traffic_reset.reset_type.manual": "Ручной сброс",
"traffic_reset.reset_type.purchase": "Приобретение пакета сброса",
"traffic_reset.source.auto": "Автоматический запуск",
"traffic_reset.source.manual": "Ручной запуск",
"traffic_reset.source.api": "Вызов API",
"traffic_reset.source.cron": "Cron-задача",
"traffic_reset.source.user_access": "Доступ пользователя",
"traffic_reset.reset_success": "Трафик успешно сброшен",
"traffic_reset.reset_failed": "Ошибка сброса трафика, подробности в логах",
"traffic_reset.user_cannot_reset": "Пользователь не может сбросить трафик (аккаунт не активен или нет действующего тарифа)"
}

View File

@@ -21,7 +21,10 @@ use Illuminate\Support\Facades\File;
Route::get('/', function (Request $request) {
if (admin_setting('app_url') && admin_setting('safe_mode_enable', 0)) {
if ($request->server('HTTP_HOST') !== parse_url(admin_setting('app_url'))['host']) {
$requestHost = $request->getHost();
$configHost = parse_url(admin_setting('app_url'), PHP_URL_HOST);
if ($requestHost !== $configHost) {
abort(403);
}
}

File diff suppressed because one or more lines are too long