feat(admin): optimize subscription template configuration and add Surfboard subscription template

- Improved the code structure for subscription template configuration.
- Added a new feature in the admin panel to configure Surfboard subscription templates.
This commit is contained in:
xboard
2025-05-16 05:13:49 +08:00
parent f5c3d5c56b
commit 417590e99c
9 changed files with 163 additions and 117 deletions
@@ -4,16 +4,25 @@ namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave; use App\Http\Requests\Admin\ConfigSave;
use App\Models\Setting; use App\Protocols\Clash;
use App\Protocols\ClashMeta;
use App\Protocols\Loon;
use App\Protocols\SingBox;
use App\Protocols\Stash;
use App\Protocols\Surfboard;
use App\Protocols\Surge;
use App\Services\MailService; use App\Services\MailService;
use App\Services\TelegramService; use App\Services\TelegramService;
use App\Services\ThemeService; use App\Services\ThemeService;
use App\Utils\Dict; use App\Utils\Dict;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class ConfigController extends Controller class ConfigController extends Controller
{ {
public function getEmailTemplate() public function getEmailTemplate()
{ {
$path = resource_path('views/mail/'); $path = resource_path('views/mail/');
@@ -48,6 +57,17 @@ class ConfigController extends Controller
'data' => $mailLog, 'data' => $mailLog,
]); ]);
} }
/**
* 获取规则模板内容
*
* @param string $file 文件路径
* @return string 文件内容
*/
private function getTemplateContent(string $file): string
{
$path = base_path($file);
return File::exists($path) ? File::get($path) : '';
}
public function setTelegramWebhook(Request $request) public function setTelegramWebhook(Request $request)
{ {
@@ -64,6 +84,18 @@ class ConfigController extends Controller
return $this->success(true); return $this->success(true);
} }
/**
* 获取自定义规则文件路径,如果不存在则返回默认文件路径
*
* @param string $customFile 自定义规则文件路径
* @param string $defaultFile 默认文件名
* @return string 文件名
*/
private function getRuleFile(string $customFile, string $defaultFile): string
{
return File::exists(base_path($customFile)) ? $customFile : $defaultFile;
}
public function fetch(Request $request) public function fetch(Request $request)
{ {
$key = $request->input('key'); $key = $request->input('key');
@@ -166,44 +198,31 @@ class ConfigController extends Controller
], ],
'subscribe_template' => [ 'subscribe_template' => [
'subscribe_template_singbox' => (function () { 'subscribe_template_singbox' => (function () {
$template = admin_setting('subscribe_template_singbox'); $content = $this->getTemplateContent(
if (!empty($template)) { $this->getRuleFile(SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE));
return is_array($template)
? json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: $template;
}
$content = file_exists(base_path('resources/rules/custom.sing-box.json'))
? file_get_contents(base_path('resources/rules/custom.sing-box.json'))
: file_get_contents(base_path('resources/rules/default.sing-box.json'));
// 确保返回格式化的 JSON 字符串
return json_encode(json_decode($content), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return json_encode(json_decode($content), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
})(), })(),
'subscribe_template_clash' => (string) (admin_setting('subscribe_template_clash') ?: ( 'subscribe_template_clash' => (string) $this->getTemplateContent(
file_exists(base_path('resources/rules/custom.clash.yaml')) $this->getRuleFile(Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE)
? file_get_contents(base_path('resources/rules/custom.clash.yaml')) ),
: file_get_contents(base_path('resources/rules/default.clash.yaml')) 'subscribe_template_clashmeta' => (string) $this->getTemplateContent(
)), $this->getRuleFile(
'subscribe_template_clashmeta' => (string) (admin_setting('subscribe_template_clashmeta') ?: ( ClashMeta::CUSTOM_TEMPLATE_FILE,
file_exists(base_path('resources/rules/custom.clashmeta.yaml')) $this->getRuleFile(ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE, ClashMeta::DEFAULT_TEMPLATE_FILE)
? file_get_contents(base_path('resources/rules/custom.clashmeta.yaml')) )
: (file_exists(base_path('resources/rules/custom.clash.yaml')) ),
? file_get_contents(base_path('resources/rules/custom.clash.yaml')) 'subscribe_template_stash' => (string) $this->getTemplateContent(
: file_get_contents(base_path('resources/rules/default.clash.yaml'))) $this->getRuleFile(
)), Stash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_stash' => (string) (admin_setting('subscribe_template_stash') ?: ( $this->getRuleFile(Stash::CUSTOM_CLASH_TEMPLATE_FILE, Stash::DEFAULT_TEMPLATE_FILE)
file_exists(base_path('resources/rules/custom.stash.yaml')) )
? file_get_contents(base_path('resources/rules/custom.stash.yaml')) ),
: (file_exists(base_path('resources/rules/custom.clash.yaml')) 'subscribe_template_surge' => (string) $this->getTemplateContent(
? file_get_contents(base_path('resources/rules/custom.clash.yaml')) $this->getRuleFile(Stash::CUSTOM_TEMPLATE_FILE, Stash::DEFAULT_TEMPLATE_FILE)
: file_get_contents(base_path('resources/rules/default.clash.yaml'))) ),
)), 'subscribe_template_surfboard' => (string) $this->getTemplateContent(
'subscribe_template_surge' => (string) (admin_setting('subscribe_template_surge') ?: ( $this->getRuleFile(Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE)
file_exists(base_path('resources/rules/custom.surge.conf')) )
? file_get_contents(base_path('resources/rules/custom.surge.conf'))
: file_get_contents(base_path('resources/rules/default.surge.conf'))
)),
] ]
]; ];
if ($key && isset($data[$key])) { if ($key && isset($data[$key])) {
@@ -219,6 +238,29 @@ class ConfigController extends Controller
public function save(ConfigSave $request) public function save(ConfigSave $request)
{ {
$data = $request->validated(); $data = $request->validated();
// 处理特殊的模板设置字段,将其保存为文件
$templateFields = [
'subscribe_template_clash' => Clash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_clashmeta' => ClashMeta::CUSTOM_TEMPLATE_FILE,
'subscribe_template_stash' => Stash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surge' => Surge::CUSTOM_TEMPLATE_FILE,
'subscribe_template_singbox' => SingBox::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surfboard' => Surfboard::CUSTOM_TEMPLATE_FILE,
];
foreach ($templateFields as $field => $filename) {
if (isset($data[$field])) {
$content = $data[$field];
// 对于JSON格式的内容,确保格式化正确
if ($field === 'subscribe_template_singbox' && is_array($content)) {
$content = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$this->saveTemplateContent($filename, $content);
unset($data[$field]); // 从数据库保存列表中移除
}
}
foreach ($data as $k => $v) { foreach ($data as $k => $v) {
if ($k == 'frontend_theme') { if ($k == 'frontend_theme') {
$themeService = app(ThemeService::class); $themeService = app(ThemeService::class);
@@ -229,4 +271,26 @@ class ConfigController extends Controller
// \Artisan::call('horizon:terminate'); //重启队列使配置生效 // \Artisan::call('horizon:terminate'); //重启队列使配置生效
return $this->success(true); return $this->success(true);
} }
/**
* 保存规则模板内容到文件
*
* @param string $filepath 文件名
* @param string $content 文件内容
* @return bool 是否保存成功
*/
private function saveTemplateContent(string $filepath, string $content): bool
{
$path = base_path($filepath);
try {
File::put($path, $content);
return true;
} catch (\Exception $e) {
Log::error('保存规则模板失败', [
'filepath' => $path,
'error' => $e->getMessage()
]);
return false;
}
}
} }
+1
View File
@@ -101,6 +101,7 @@ class ConfigSave extends FormRequest
'subscribe_template_clashmeta' => 'nullable', 'subscribe_template_clashmeta' => 'nullable',
'subscribe_template_stash' => 'nullable', 'subscribe_template_stash' => 'nullable',
'subscribe_template_surge' => 'nullable', 'subscribe_template_surge' => 'nullable',
'subscribe_template_surfboard' => 'nullable'
]; ];
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
+6 -11
View File
@@ -4,6 +4,7 @@ namespace App\Protocols;
use App\Contracts\ProtocolInterface; use App\Contracts\ProtocolInterface;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class Clash implements ProtocolInterface class Clash implements ProtocolInterface
@@ -11,6 +12,8 @@ class Clash implements ProtocolInterface
public $flags = ['clash']; public $flags = ['clash'];
private $servers; private $servers;
private $user; private $user;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.clash.yaml';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.clash.yaml';
public function __construct($user, $servers) public function __construct($user, $servers)
{ {
@@ -29,17 +32,9 @@ class Clash implements ProtocolInterface
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
// 优先从 admin_setting 获取模板 $template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_clash'); ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
if (empty($template)) { : File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
$defaultConfig = base_path('resources/rules/default.clash.yaml');
$customConfig = base_path('resources/rules/custom.clash.yaml');
if (file_exists($customConfig)) {
$template = file_get_contents($customConfig);
} else {
$template = file_get_contents($defaultConfig);
}
}
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
+15 -17
View File
@@ -5,6 +5,7 @@ namespace App\Protocols;
use App\Contracts\ProtocolInterface; use App\Contracts\ProtocolInterface;
use App\Models\ServerHysteria; use App\Models\ServerHysteria;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class ClashMeta implements ProtocolInterface class ClashMeta implements ProtocolInterface
@@ -12,6 +13,9 @@ class ClashMeta implements ProtocolInterface
public $flags = ['meta', 'verge', 'flclash']; public $flags = ['meta', 'verge', 'flclash'];
private $servers; private $servers;
private $user; private $user;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.clashmeta.yaml';
const CUSTOM_CLASH_TEMPLATE_FILE = 'resources/rules/custom.clash.yaml';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.clash.yaml';
/** /**
* @param mixed $user 用户实例 * @param mixed $user 用户实例
@@ -28,31 +32,25 @@ class ClashMeta implements ProtocolInterface
return $this->flags; return $this->flags;
} }
public function handle() public function handle()
{ {
$servers = $this->servers; $servers = $this->servers;
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
// 优先从 admin_setting 获取模板 $template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_clashmeta'); ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
if (empty($template)) { : (
$defaultConfig = base_path('resources/rules/default.clash.yaml'); File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
$customClashConfig = base_path('resources/rules/custom.clash.yaml'); ? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
$customConfig = base_path('resources/rules/custom.clashmeta.yaml'); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
if (file_exists($customConfig)) { );
$template = file_get_contents($customConfig);
} elseif (file_exists($customClashConfig)) {
$template = file_get_contents($customClashConfig);
} else {
$template = file_get_contents($defaultConfig);
}
}
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
$proxies = []; $proxies = [];
foreach ($servers as $item) { foreach ($servers as $item) {
$protocol_settings = $item['protocol_settings']; $protocol_settings = $item['protocol_settings'];
if ($item['type'] === 'shadowsocks') { if ($item['type'] === 'shadowsocks') {
+10 -13
View File
@@ -4,6 +4,7 @@ namespace App\Protocols;
use App\Utils\Helper; use App\Utils\Helper;
use App\Contracts\ProtocolInterface; use App\Contracts\ProtocolInterface;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
class SingBox implements ProtocolInterface class SingBox implements ProtocolInterface
{ {
@@ -11,6 +12,8 @@ class SingBox implements ProtocolInterface
private $servers; private $servers;
private $user; private $user;
private $config; private $config;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.sing-box.json';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.sing-box.json';
public function __construct($user, $servers) public function __construct($user, $servers)
{ {
@@ -40,15 +43,9 @@ class SingBox implements ProtocolInterface
protected function loadConfig() protected function loadConfig()
{ {
// 优先从 admin_setting 获取模板 $jsonData = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_singbox'); ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
if (!empty($template)) { : File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
return is_array($template) ? $template : json_decode($template, true);
}
$defaultConfig = base_path('resources/rules/default.sing-box.json');
$customConfig = base_path('resources/rules/custom.sing-box.json');
$jsonData = file_exists($customConfig) ? file_get_contents($customConfig) : file_get_contents($defaultConfig);
return json_decode($jsonData, true); return json_decode($jsonData, true);
} }
@@ -227,7 +224,7 @@ class SingBox implements ProtocolInterface
$transport = match ($protocol_settings['network']) { $transport = match ($protocol_settings['network']) {
'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [ 'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [
'type' => 'http', 'type' => 'http',
'path' => \Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])) 'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/']))
] : null, ] : null,
'ws' => array_filter([ 'ws' => array_filter([
'type' => 'ws', 'type' => 'ws',
@@ -308,9 +305,9 @@ class SingBox implements ProtocolInterface
'insecure' => (bool) $protocol_settings['tls']['allow_insecure'], 'insecure' => (bool) $protocol_settings['tls']['allow_insecure'],
] ]
]; ];
if (isset($server['ports'])) { // if (isset($server['ports'])) {
$baseConfig['server_ports'][] = str_replace('-', ':', $server['ports']); // $baseConfig['server_ports'][] = str_replace('-', ':', $server['ports']);
} // }
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
$baseConfig['tls']['server_name'] = $serverName; $baseConfig['tls']['server_name'] = $serverName;
} }
+14 -16
View File
@@ -5,6 +5,7 @@ namespace App\Protocols;
use App\Models\ServerHysteria; use App\Models\ServerHysteria;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use App\Contracts\ProtocolInterface; use App\Contracts\ProtocolInterface;
use Illuminate\Support\Facades\File;
class Stash implements ProtocolInterface class Stash implements ProtocolInterface
{ {
@@ -12,6 +13,10 @@ class Stash implements ProtocolInterface
private $servers; private $servers;
private $user; private $user;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.stash.yaml';
const CUSTOM_CLASH_TEMPLATE_FILE = 'resources/rules/custom.clash.yaml';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.clash.yaml';
public function __construct($user, $servers) public function __construct($user, $servers)
{ {
$this->user = $user; $this->user = $user;
@@ -28,22 +33,15 @@ class Stash implements ProtocolInterface
$servers = $this->servers; $servers = $this->servers;
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
// 优先从 admin_setting 获取模板 $template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$template = admin_setting('subscribe_template_stash'); ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
if (empty($template)) { : (
$defaultConfig = base_path('resources/rules/default.clash.yaml'); File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
$customClashConfig = base_path('resources/rules/custom.clash.yaml'); ? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
$customStashConfig = base_path('resources/rules/custom.stash.yaml'); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
if (file_exists($customStashConfig)) { );
$template = file_get_contents($customStashConfig);
} elseif (file_exists($customClashConfig)) {
$template = file_get_contents($customClashConfig);
} else {
$template = file_get_contents($defaultConfig);
}
}
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
$proxies = []; $proxies = [];
+5 -8
View File
@@ -11,6 +11,8 @@ class Surfboard implements ProtocolInterface
public $flags = ['surfboard']; public $flags = ['surfboard'];
private $servers; private $servers;
private $user; private $user;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.surfboard.conf';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.surfboard.conf';
public function __construct($user, $servers) public function __construct($user, $servers)
{ {
@@ -62,14 +64,9 @@ class Surfboard implements ProtocolInterface
} }
} }
$defaultConfig = base_path() . '/resources/rules/default.surfboard.conf'; $config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
$customConfig = base_path() . '/resources/rules/custom.surfboard.conf'; ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
if (File::exists($customConfig)) { : File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
$config = file_get_contents("$customConfig");
} else {
$config = file_get_contents("$defaultConfig");
}
// Subscription link // Subscription link
$subsURL = Helper::getSubscribeUrl($user['token']); $subsURL = Helper::getSubscribeUrl($user['token']);
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
+7 -11
View File
@@ -4,12 +4,15 @@ namespace App\Protocols;
use App\Utils\Helper; use App\Utils\Helper;
use App\Contracts\ProtocolInterface; use App\Contracts\ProtocolInterface;
use Illuminate\Support\Facades\File;
class Surge implements ProtocolInterface class Surge implements ProtocolInterface
{ {
public $flags = ['surge']; public $flags = ['surge'];
private $servers; private $servers;
private $user; private $user;
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.surge.conf';
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.surge.conf';
public function __construct($user, $servers) public function __construct($user, $servers)
{ {
@@ -59,17 +62,10 @@ class Surge implements ProtocolInterface
} }
} }
// 优先从 admin_setting 获取模板
$config = admin_setting('subscribe_template_surge'); $config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
if (empty($config)) { ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
$defaultConfig = base_path('resources/rules/default.surge.conf'); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE));
$customConfig = base_path('resources/rules/custom.surge.conf');
if (file_exists($customConfig)) {
$config = file_get_contents($customConfig);
} else {
$config = file_get_contents($defaultConfig);
}
}
// Subscription link // Subscription link
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
File diff suppressed because one or more lines are too long