merge: sync upstream/master preserving local changes
This commit is contained in:
@@ -178,7 +178,6 @@ class ConfigController extends Controller
|
||||
'server_ws_url' => admin_setting('server_ws_url', ''),
|
||||
],
|
||||
'email' => [
|
||||
'email_template' => admin_setting('email_template', 'default'),
|
||||
'email_host' => admin_setting('email_host'),
|
||||
'email_port' => admin_setting('email_port'),
|
||||
'email_username' => admin_setting('email_username'),
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V2\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Services\MailService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailTemplateController extends Controller
|
||||
{
|
||||
public function list()
|
||||
{
|
||||
$dbTemplates = MailTemplate::all()->keyBy('name');
|
||||
|
||||
$result = [];
|
||||
foreach (MailTemplate::TEMPLATES as $name => $meta) {
|
||||
$db = $dbTemplates->get($name);
|
||||
$result[] = [
|
||||
'name' => $name,
|
||||
'label' => $meta['label'],
|
||||
'customized' => $db !== null,
|
||||
'subject' => $db?->subject,
|
||||
'updated_at' => $db?->updated_at?->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
public function get(Request $request)
|
||||
{
|
||||
$name = $request->input('name');
|
||||
$meta = MailTemplate::getMeta($name);
|
||||
if (!$meta) {
|
||||
return $this->fail([404, '模板不存在']);
|
||||
}
|
||||
|
||||
$db = MailTemplate::where('name', $name)->first();
|
||||
|
||||
return $this->success([
|
||||
'name' => $name,
|
||||
'label' => $meta['label'],
|
||||
'required_vars' => $meta['required_vars'],
|
||||
'optional_vars' => $meta['optional_vars'],
|
||||
'customized' => $db !== null,
|
||||
'subject' => $db?->subject ?? $this->getDefaultSubject($name),
|
||||
'content' => $db?->content ?? $this->getDefaultContent($name),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$params = $request->validate([
|
||||
'name' => 'required|string',
|
||||
'subject' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
]);
|
||||
|
||||
$meta = MailTemplate::getMeta($params['name']);
|
||||
if (!$meta) {
|
||||
return $this->fail([404, '模板不存在']);
|
||||
}
|
||||
|
||||
$errors = MailTemplate::validateContent($params['name'], $params['content']);
|
||||
if (!empty($errors)) {
|
||||
return $this->fail([422, implode('; ', $errors)]);
|
||||
}
|
||||
|
||||
MailTemplate::updateOrCreate(
|
||||
['name' => $params['name']],
|
||||
['subject' => $params['subject'], 'content' => $params['content']]
|
||||
);
|
||||
Cache::forget("mail_template:{$params['name']}");
|
||||
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$name = $request->input('name');
|
||||
$meta = MailTemplate::getMeta($name);
|
||||
if (!$meta) {
|
||||
return $this->fail([404, '模板不存在']);
|
||||
}
|
||||
|
||||
MailTemplate::where('name', $name)->delete();
|
||||
Cache::forget("mail_template:{$name}");
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
public function test(Request $request)
|
||||
{
|
||||
$name = $request->input('name');
|
||||
$meta = MailTemplate::getMeta($name);
|
||||
if (!$meta) {
|
||||
return $this->fail([404, '模板不存在']);
|
||||
}
|
||||
|
||||
$email = $request->input('email', $request->user()->email);
|
||||
$testVars = $this->getTestVars($name);
|
||||
|
||||
try {
|
||||
$log = MailService::sendEmail([
|
||||
'email' => $email,
|
||||
'subject' => $this->getTestSubject($name),
|
||||
'template_name' => $name,
|
||||
'template_value' => $testVars,
|
||||
]);
|
||||
|
||||
if ($log['error']) {
|
||||
return $this->fail([500, '发送失败: ' . $log['error']]);
|
||||
}
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
return $this->fail([500, '发送失败: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getTestSubject(string $name): string
|
||||
{
|
||||
$appName = admin_setting('app_name', 'XBoard');
|
||||
return match ($name) {
|
||||
'verify' => "{$appName} - 验证码测试",
|
||||
'notify' => "{$appName} - 通知测试",
|
||||
'remindExpire' => "{$appName} - 到期提醒测试",
|
||||
'remindTraffic' => "{$appName} - 流量提醒测试",
|
||||
'mailLogin' => "{$appName} - 登录链接测试",
|
||||
default => "{$appName} - 邮件测试",
|
||||
};
|
||||
}
|
||||
|
||||
private function getTestVars(string $name): array
|
||||
{
|
||||
$appName = admin_setting('app_name', 'XBoard');
|
||||
$appUrl = admin_setting('app_url', 'https://example.com');
|
||||
|
||||
return match ($name) {
|
||||
'verify' => [
|
||||
'name' => $appName,
|
||||
'code' => '123456',
|
||||
'url' => $appUrl,
|
||||
],
|
||||
'notify' => [
|
||||
'name' => $appName,
|
||||
'content' => '这是一封测试通知邮件。',
|
||||
'url' => $appUrl,
|
||||
],
|
||||
'remindExpire' => [
|
||||
'name' => $appName,
|
||||
'url' => $appUrl,
|
||||
],
|
||||
'remindTraffic' => [
|
||||
'name' => $appName,
|
||||
'url' => $appUrl,
|
||||
],
|
||||
'mailLogin' => [
|
||||
'name' => $appName,
|
||||
'link' => $appUrl . '/login?token=test-token',
|
||||
'url' => $appUrl,
|
||||
],
|
||||
default => ['name' => $appName, 'url' => $appUrl],
|
||||
};
|
||||
}
|
||||
|
||||
private function getDefaultSubject(string $name): string
|
||||
{
|
||||
$appName = admin_setting('app_name', 'XBoard');
|
||||
return match ($name) {
|
||||
'verify' => "{$appName} - 邮箱验证码",
|
||||
'notify' => "{$appName} - 站点通知",
|
||||
'remindExpire' => "{$appName} - 服务即将到期",
|
||||
'remindTraffic' => "{$appName} - 流量使用提醒",
|
||||
'mailLogin' => "{$appName} - 邮件登录",
|
||||
default => "{$appName}",
|
||||
};
|
||||
}
|
||||
|
||||
private function getDefaultContent(string $name): string
|
||||
{
|
||||
$theme = 'default';
|
||||
$viewName = "mail.{$theme}.{$name}";
|
||||
|
||||
try {
|
||||
$viewPath = resource_path("views/mail/{$theme}/{$name}.blade.php");
|
||||
if (file_exists($viewPath)) {
|
||||
$blade = file_get_contents($viewPath);
|
||||
return self::bladeToPlaceholder($blade);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return self::hardcodedDefault($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Blade syntax to {{placeholder}} syntax for editing.
|
||||
*/
|
||||
private static function bladeToPlaceholder(string $blade): string
|
||||
{
|
||||
// {{$var}} → {{var}}
|
||||
$result = preg_replace('/\{\{\s*\$([a-zA-Z_]+)\s*\}\}/', '{{$1}}', $blade);
|
||||
// {!! nl2br($var) !!} → {{var}}
|
||||
$result = preg_replace('/\{!!\s*nl2br\(\$([a-zA-Z_]+)\)\s*!!\}/', '{{$1}}', $result);
|
||||
// {!! $var !!} → {{var}}
|
||||
$result = preg_replace('/\{!!\s*\$([a-zA-Z_]+)\s*!!\}/', '{{$1}}', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function hardcodedDefault(string $name): string
|
||||
{
|
||||
$layout = fn($title, $body) => <<<HTML
|
||||
<div style="background: #eee">
|
||||
<table width="600" border="0" align="center" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div style="background:#fff">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<td valign="middle" style="padding-left:30px;background-color:#415A94;color:#fff;padding:20px 40px;font-size: 21px;">{{name}}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="padding:40px 40px 0 40px;display:table-cell">
|
||||
<td style="font-size:24px;line-height:1.5;color:#000;margin-top:40px">{$title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;color:#333;padding:24px 40px 0 40px">
|
||||
尊敬的用户您好!<br /><br />{$body}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:20px 40px;font-size:12px;color:#999;line-height:20px;background:#f7f7f7"><a href="{{url}}" style="font-size:14px;color:#929292">返回{{name}}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return match ($name) {
|
||||
'verify' => $layout('邮箱验证码', '您的验证码是:{{code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。'),
|
||||
'notify' => $layout('网站通知', '{{content}}'),
|
||||
'remindExpire' => $layout('服务到期提醒', '您的服务即将在24小时内到期,如需继续使用请及时续费。'),
|
||||
'remindTraffic' => $layout('流量使用提醒', '您的流量使用已达到80%,请注意流量使用情况。'),
|
||||
'mailLogin' => $layout('登入到{{name}}', '您正在登入到{{name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。<a href="{{link}}">{{link}}</a>'),
|
||||
default => $layout('通知', '{{content}}'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class PaymentController extends Controller
|
||||
|
||||
public function fetch()
|
||||
{
|
||||
$payments = Payment::orderBy('sort', 'ASC')->get();
|
||||
$payments = Payment::orderBy('sort', 'ASC')->get()->makeVisible('config');
|
||||
foreach ($payments as $k => $v) {
|
||||
$notifyUrl = url("/api/v1/guest/payment/notify/{$v->payment}/{$v->uuid}");
|
||||
if ($v->notify_domain) {
|
||||
|
||||
@@ -60,56 +60,67 @@ class PluginController extends Controller
|
||||
->keyBy('code')
|
||||
->toArray();
|
||||
|
||||
$pluginPath = base_path('plugins');
|
||||
$plugins = [];
|
||||
$seenCodes = [];
|
||||
|
||||
if (File::exists($pluginPath)) {
|
||||
foreach ($this->pluginManager->getPluginPaths() as $pluginPath) {
|
||||
if (!File::exists($pluginPath)) {
|
||||
continue;
|
||||
}
|
||||
$directories = File::directories($pluginPath);
|
||||
foreach ($directories as $directory) {
|
||||
$pluginName = basename($directory);
|
||||
$configFile = $directory . '/config.json';
|
||||
if (File::exists($configFile)) {
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
$code = $config['code'];
|
||||
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
|
||||
|
||||
// 如果指定了类型,过滤插件
|
||||
if ($type && $pluginType !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$installed = isset($installedPlugins[$code]);
|
||||
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
|
||||
$readmeFile = collect(['README.md', 'readme.md'])
|
||||
->map(fn($f) => $directory . '/' . $f)
|
||||
->first(fn($path) => File::exists($path));
|
||||
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
|
||||
$needUpgrade = false;
|
||||
if ($installed) {
|
||||
$installedVersion = $installedPlugins[$code]['version'] ?? null;
|
||||
$localVersion = $config['version'] ?? null;
|
||||
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
|
||||
$needUpgrade = true;
|
||||
}
|
||||
}
|
||||
$plugins[] = [
|
||||
'code' => $config['code'],
|
||||
'name' => $config['name'],
|
||||
'version' => $config['version'],
|
||||
'description' => $config['description'],
|
||||
'author' => $config['author'],
|
||||
'type' => $pluginType,
|
||||
'is_installed' => $installed,
|
||||
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
|
||||
'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS),
|
||||
'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS),
|
||||
'config' => $pluginConfig,
|
||||
'readme' => $readmeContent,
|
||||
'need_upgrade' => $needUpgrade,
|
||||
'admin_menus' => $config['admin_menus'] ?? null,
|
||||
'admin_crud' => $config['admin_crud'] ?? null,
|
||||
];
|
||||
if (!File::exists($configFile)) {
|
||||
continue;
|
||||
}
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (!$config || !isset($config['code'])) {
|
||||
continue;
|
||||
}
|
||||
$code = $config['code'];
|
||||
|
||||
if (isset($seenCodes[$code])) {
|
||||
continue;
|
||||
}
|
||||
$seenCodes[$code] = true;
|
||||
|
||||
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
|
||||
if ($type && $pluginType !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$installed = isset($installedPlugins[$code]);
|
||||
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
|
||||
$readmeFile = collect(['README.md', 'readme.md'])
|
||||
->map(fn($f) => $directory . '/' . $f)
|
||||
->first(fn($path) => File::exists($path));
|
||||
$readmeContent = $readmeFile ? File::get($readmeFile) : '';
|
||||
$needUpgrade = false;
|
||||
if ($installed) {
|
||||
$installedVersion = $installedPlugins[$code]['version'] ?? null;
|
||||
$localVersion = $config['version'] ?? null;
|
||||
if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) {
|
||||
$needUpgrade = true;
|
||||
}
|
||||
}
|
||||
$isCore = $this->pluginManager->isCorePlugin($code);
|
||||
$plugins[] = [
|
||||
'code' => $config['code'],
|
||||
'name' => $config['name'],
|
||||
'version' => $config['version'],
|
||||
'description' => $config['description'],
|
||||
'author' => $config['author'],
|
||||
'type' => $pluginType,
|
||||
'is_installed' => $installed,
|
||||
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
|
||||
'is_protected' => $isCore,
|
||||
'can_be_deleted' => !$isCore,
|
||||
'config' => $pluginConfig,
|
||||
'readme' => $readmeContent,
|
||||
'need_upgrade' => $needUpgrade,
|
||||
'admin_menus' => $config['admin_menus'] ?? null,
|
||||
'admin_crud' => $config['admin_crud'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,10 +325,10 @@ class PluginController extends Controller
|
||||
|
||||
$code = $request->input('code');
|
||||
|
||||
// 检查是否为受保护的插件
|
||||
if (in_array($code, Plugin::PROTECTED_PLUGINS)) {
|
||||
// 检查是否为核心插件
|
||||
if ($this->pluginManager->isCorePlugin($code)) {
|
||||
return response()->json([
|
||||
'message' => '该插件为系统默认插件,不允许删除'
|
||||
'message' => '该插件为系统核心插件,不允许删除'
|
||||
], 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,12 +168,19 @@ class MachineController extends Controller
|
||||
$params = $request->validate([
|
||||
'machine_id' => 'required|integer|exists:v2_server_machine,id',
|
||||
'limit' => 'nullable|integer|min:10|max:1440',
|
||||
'range_hours' => 'nullable|integer|min:1|max:24',
|
||||
]);
|
||||
|
||||
$query = ServerMachineLoadHistory::query()
|
||||
->where('machine_id', $params['machine_id']);
|
||||
|
||||
if (!empty($params['range_hours'])) {
|
||||
$query->where('recorded_at', '>=', now()->subHours((int) $params['range_hours'])->timestamp);
|
||||
}
|
||||
|
||||
$limit = (int) ($params['limit'] ?? 60);
|
||||
|
||||
$history = ServerMachineLoadHistory::query()
|
||||
->where('machine_id', $params['machine_id'])
|
||||
$history = $query
|
||||
->orderByDesc('recorded_at')
|
||||
->limit($limit)
|
||||
->get([
|
||||
@@ -182,6 +189,8 @@ class MachineController extends Controller
|
||||
'mem_used',
|
||||
'disk_total',
|
||||
'disk_used',
|
||||
'net_in_speed',
|
||||
'net_out_speed',
|
||||
'recorded_at',
|
||||
])
|
||||
->reverse()
|
||||
|
||||
@@ -225,6 +225,8 @@ class ManageController extends Controller
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'integer',
|
||||
'show' => 'nullable|integer|in:0,1',
|
||||
'enabled' => 'nullable|boolean',
|
||||
'machine_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$ids = $params['ids'];
|
||||
@@ -236,13 +238,25 @@ class ManageController extends Controller
|
||||
if (array_key_exists('show', $params) && $params['show'] !== null) {
|
||||
$update['show'] = (int) $params['show'];
|
||||
}
|
||||
if (array_key_exists('enabled', $params) && $params['enabled'] !== null) {
|
||||
$update['enabled'] = (bool) $params['enabled'];
|
||||
}
|
||||
if (array_key_exists('machine_id', $params)) {
|
||||
$update['machine_id'] = $params['machine_id'] ?: null;
|
||||
}
|
||||
|
||||
if (empty($update)) {
|
||||
return $this->fail([400, '没有可更新的字段']);
|
||||
}
|
||||
|
||||
try {
|
||||
Server::whereIn('id', $ids)->update($update);
|
||||
$servers = Server::whereIn('id', $ids)->get();
|
||||
DB::transaction(function () use ($servers, $update) {
|
||||
/** @var Server $server */
|
||||
foreach ($servers as $server) {
|
||||
$server->update($update);
|
||||
}
|
||||
});
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
|
||||
@@ -55,6 +55,7 @@ class TicketController extends Controller
|
||||
if (!$ticket) {
|
||||
return $this->fail([400202, '工单不存在']);
|
||||
}
|
||||
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
|
||||
$result = $ticket->toArray();
|
||||
$result['user'] = UserController::transformUserData($ticket->user);
|
||||
|
||||
@@ -144,11 +145,12 @@ class TicketController extends Controller
|
||||
$ticket = Ticket::with([
|
||||
'user',
|
||||
'messages' => function ($query) {
|
||||
$query->with(['user']); // 如果需要用户信息
|
||||
$query->with(['user']);
|
||||
}
|
||||
])->findOrFail($ticketId);
|
||||
|
||||
// 自动包含 is_me 属性
|
||||
$ticket->messages->each(fn($msg) => $msg->setRelation('ticket', $ticket));
|
||||
|
||||
return response()->json([
|
||||
'data' => $ticket
|
||||
]);
|
||||
|
||||
@@ -50,32 +50,46 @@ class MachineController extends Controller
|
||||
'swap.used' => 'nullable|integer|min:0',
|
||||
'disk.total' => 'nullable|integer|min:0',
|
||||
'disk.used' => 'nullable|integer|min:0',
|
||||
'net.in_speed' => 'nullable|numeric|min:0',
|
||||
'net.out_speed' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$machine = $this->authenticateMachine($request);
|
||||
$recordedAt = now()->timestamp;
|
||||
|
||||
$machine->forceFill([
|
||||
'load_status' => [
|
||||
'cpu' => (float) $request->input('cpu'),
|
||||
'mem' => [
|
||||
'total' => (int) $request->input('mem.total'),
|
||||
'used' => (int) $request->input('mem.used'),
|
||||
],
|
||||
'swap' => [
|
||||
'total' => (int) $request->input('swap.total', 0),
|
||||
'used' => (int) $request->input('swap.used', 0),
|
||||
],
|
||||
'disk' => [
|
||||
'total' => (int) $request->input('disk.total', 0),
|
||||
'used' => (int) $request->input('disk.used', 0),
|
||||
],
|
||||
'updated_at' => $recordedAt,
|
||||
$loadStatus = [
|
||||
'cpu' => (float) $request->input('cpu'),
|
||||
'mem' => [
|
||||
'total' => (int) $request->input('mem.total'),
|
||||
'used' => (int) $request->input('mem.used'),
|
||||
],
|
||||
'swap' => [
|
||||
'total' => (int) $request->input('swap.total', 0),
|
||||
'used' => (int) $request->input('swap.used', 0),
|
||||
],
|
||||
'disk' => [
|
||||
'total' => (int) $request->input('disk.total', 0),
|
||||
'used' => (int) $request->input('disk.used', 0),
|
||||
],
|
||||
'updated_at' => $recordedAt,
|
||||
];
|
||||
|
||||
$netInSpeed = $request->input('net.in_speed');
|
||||
$netOutSpeed = $request->input('net.out_speed');
|
||||
|
||||
if ($netInSpeed !== null && $netOutSpeed !== null) {
|
||||
$loadStatus['net'] = [
|
||||
'in_speed' => (float) $netInSpeed,
|
||||
'out_speed' => (float) $netOutSpeed,
|
||||
];
|
||||
}
|
||||
|
||||
$machine->forceFill([
|
||||
'load_status' => $loadStatus,
|
||||
'last_seen_at' => $recordedAt,
|
||||
])->save();
|
||||
|
||||
ServerMachineLoadHistory::create([
|
||||
$historyData = [
|
||||
'machine_id' => $machine->id,
|
||||
'cpu' => (float) $request->input('cpu'),
|
||||
'mem_total' => (int) $request->input('mem.total'),
|
||||
@@ -83,7 +97,14 @@ class MachineController extends Controller
|
||||
'disk_total' => (int) $request->input('disk.total', 0),
|
||||
'disk_used' => (int) $request->input('disk.used', 0),
|
||||
'recorded_at' => $recordedAt,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($netInSpeed !== null && $netOutSpeed !== null) {
|
||||
$historyData['net_in_speed'] = (float) $netInSpeed;
|
||||
$historyData['net_out_speed'] = (float) $netOutSpeed;
|
||||
}
|
||||
|
||||
ServerMachineLoadHistory::create($historyData);
|
||||
|
||||
// Time-based cleanup: keep 24h of data, runs on ~5% of requests
|
||||
if (random_int(1, 20) === 1) {
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\V2\Server;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ServerService;
|
||||
use App\WebSocket\NodeWorker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ServerController extends Controller
|
||||
{
|
||||
@@ -16,14 +18,14 @@ class ServerController extends Controller
|
||||
{
|
||||
$websocket = ['enabled' => false];
|
||||
|
||||
if ((bool) admin_setting('server_ws_enable', 1)) {
|
||||
if ((bool) admin_setting('server_ws_enable', 1) && Cache::has(NodeWorker::HEARTBEAT_CACHE_KEY)) {
|
||||
$customUrl = trim((string) admin_setting('server_ws_url', ''));
|
||||
|
||||
if ($customUrl !== '') {
|
||||
$wsUrl = rtrim($customUrl, '/');
|
||||
} else {
|
||||
$wsScheme = $request->isSecure() ? 'wss' : 'ws';
|
||||
$wsUrl = "{$wsScheme}://{$request->getHost()}:8076";
|
||||
$wsUrl = "{$wsScheme}://{$request->getHttpHost()}/ws";
|
||||
}
|
||||
|
||||
$websocket = [
|
||||
|
||||
@@ -77,6 +77,7 @@ class Kernel extends HttpKernel
|
||||
'staff' => \App\Http\Middleware\Staff::class,
|
||||
'log' => \App\Http\Middleware\RequestLog::class,
|
||||
'server' => \App\Http\Middleware\Server::class,
|
||||
'server.v2' => \App\Http\Middleware\ServerV2::class,
|
||||
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
|
||||
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||
];
|
||||
|
||||
@@ -4,29 +4,16 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Server as ServerModel;
|
||||
use App\Models\ServerMachine;
|
||||
use App\Services\ServerService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @deprecated use {@see ServerV2}
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
public function handle(Request $request, Closure $next, ?string $nodeType = null)
|
||||
{
|
||||
// 优先尝试 machine token 认证,兜底走旧的 server token 认证
|
||||
if ($request->filled('machine_id')) {
|
||||
$this->authenticateByMachine($request, $nodeType);
|
||||
} else {
|
||||
$this->authenticateByServerToken($request, $nodeType);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧模式:全局 server_token + node_id
|
||||
*/
|
||||
private function authenticateByServerToken(Request $request, ?string $nodeType): void
|
||||
{
|
||||
$request->validate([
|
||||
'token' => [
|
||||
@@ -64,55 +51,7 @@ class Server
|
||||
}
|
||||
|
||||
$request->attributes->set('node_info', $serverInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新模式:machine_id + machine token + node_id
|
||||
*
|
||||
* machine 认证后,node_id 必须属于该 machine 下的已启用节点。
|
||||
* 下游控制器拿到的 node_info 与旧模式完全一致。
|
||||
*/
|
||||
private function authenticateByMachine(Request $request, ?string $nodeType): void
|
||||
{
|
||||
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
||||
|
||||
$request->validate([
|
||||
'machine_id' => 'required|integer',
|
||||
'token' => 'required|string',
|
||||
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
|
||||
]);
|
||||
|
||||
$machine = ServerMachine::where('id', $request->input('machine_id'))
|
||||
->where('token', $request->input('token'))
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
throw new ApiException('Machine not found or invalid token', 401);
|
||||
}
|
||||
|
||||
if (!$machine->is_active) {
|
||||
throw new ApiException('Machine is disabled', 403);
|
||||
}
|
||||
|
||||
$nodeId = (int) $request->input('node_id');
|
||||
$serverInfo = null;
|
||||
|
||||
if ($nodeId > 0) {
|
||||
$serverInfo = ServerModel::where('id', $nodeId)
|
||||
->where('machine_id', $machine->id)
|
||||
->where('enabled', true)
|
||||
->first();
|
||||
|
||||
if (!$serverInfo) {
|
||||
throw new ApiException('Node not found on this machine');
|
||||
}
|
||||
|
||||
$request->attributes->set('node_info', $serverInfo);
|
||||
}
|
||||
|
||||
// 更新机器心跳
|
||||
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
|
||||
|
||||
$request->attributes->set('machine_info', $machine);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Server as ServerModel;
|
||||
use App\Models\ServerMachine;
|
||||
use App\Services\ServerService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* V2 server middleware: machine-token or server-token auth, no node_type.
|
||||
*/
|
||||
class ServerV2
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if ($request->filled('machine_id')) {
|
||||
$this->authenticateByMachine($request);
|
||||
} else {
|
||||
$this->authenticateByServerToken($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function authenticateByServerToken(Request $request): void
|
||||
{
|
||||
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
||||
|
||||
$request->validate([
|
||||
'token' => [
|
||||
'string', 'required',
|
||||
function ($attribute, $value, $fail) {
|
||||
if ($value !== admin_setting('server_token')) {
|
||||
$fail("Invalid {$attribute}");
|
||||
}
|
||||
},
|
||||
],
|
||||
'node_id' => $isHandshake ? 'nullable' : 'required',
|
||||
]);
|
||||
|
||||
$nodeId = $request->input('node_id');
|
||||
if ($nodeId === null || $nodeId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$serverInfo = ServerService::getServer($nodeId);
|
||||
if (!$serverInfo) {
|
||||
throw new ApiException('Server does not exist');
|
||||
}
|
||||
|
||||
$request->attributes->set('node_info', $serverInfo);
|
||||
}
|
||||
|
||||
private function authenticateByMachine(Request $request): void
|
||||
{
|
||||
$isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake');
|
||||
|
||||
$request->validate([
|
||||
'machine_id' => 'required|integer',
|
||||
'token' => 'required|string',
|
||||
'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer',
|
||||
]);
|
||||
|
||||
$machine = ServerMachine::where('id', $request->input('machine_id'))
|
||||
->where('token', $request->input('token'))
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
throw new ApiException('Machine not found or invalid token', 401);
|
||||
}
|
||||
|
||||
if (!$machine->is_active) {
|
||||
throw new ApiException('Machine is disabled', 403);
|
||||
}
|
||||
|
||||
$nodeId = (int) $request->input('node_id');
|
||||
if ($nodeId > 0) {
|
||||
$serverInfo = ServerModel::where('id', $nodeId)
|
||||
->where('machine_id', $machine->id)
|
||||
->where('enabled', true)
|
||||
->first();
|
||||
|
||||
if (!$serverInfo) {
|
||||
throw new ApiException('Node not found on this machine');
|
||||
}
|
||||
|
||||
$request->attributes->set('node_info', $serverInfo);
|
||||
}
|
||||
|
||||
$machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly();
|
||||
|
||||
$request->attributes->set('machine_info', $machine);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ class ConfigSave extends FormRequest
|
||||
'frontend_theme_color' => 'nullable|in:default,darkblue,black,green',
|
||||
'frontend_background_url' => 'nullable|url',
|
||||
// email
|
||||
'email_template' => '',
|
||||
'email_host' => '',
|
||||
'email_port' => '',
|
||||
'email_username' => '',
|
||||
|
||||
@@ -54,6 +54,7 @@ class ServerSave extends FormRequest
|
||||
'tls' => 'required|integer',
|
||||
'network' => 'required|string',
|
||||
'network_settings' => 'nullable|array',
|
||||
'rules' => 'nullable|array',
|
||||
],
|
||||
'trojan' => [
|
||||
'tls' => 'nullable|integer',
|
||||
@@ -91,6 +92,12 @@ class ServerSave extends FormRequest
|
||||
'http' => [
|
||||
'tls' => 'required|integer',
|
||||
],
|
||||
'tuic' => [
|
||||
'version' => 'nullable|integer',
|
||||
'congestion_control' => 'nullable|string',
|
||||
'alpn' => 'nullable|array',
|
||||
'udp_relay_mode' => 'nullable|string',
|
||||
],
|
||||
'mieru' => [
|
||||
'transport' => 'required|string|in:TCP,UDP',
|
||||
'traffic_pattern' => 'string',
|
||||
@@ -161,6 +168,10 @@ class ServerSave extends FormRequest
|
||||
$rules,
|
||||
$this->buildTlsObjectRules(),
|
||||
),
|
||||
'mieru' => array_merge(
|
||||
$rules,
|
||||
self::MULTIPLEX_RULES,
|
||||
),
|
||||
'vless' => array_merge(
|
||||
$rules,
|
||||
$this->buildTlsSettingsRules(),
|
||||
|
||||
@@ -23,6 +23,12 @@ class OrderResource extends JsonResource
|
||||
...parent::toArray($request),
|
||||
'period' => PlanService::getLegacyPeriod((string)$this->period),
|
||||
'plan' => $this->whenLoaded('plan', fn() => PlanResource::make($this->plan)),
|
||||
'payment' => $this->whenLoaded('payment', fn() => $this->payment ? [
|
||||
'id' => $this->payment->id,
|
||||
'name' => $this->payment->name,
|
||||
'payment' => $this->payment->payment,
|
||||
'icon' => $this->payment->icon,
|
||||
] : null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace App\Http\Routes\V2;
|
||||
|
||||
use App\Http\Controllers\V2\Admin\ConfigController;
|
||||
use App\Http\Controllers\V2\Admin\MailTemplateController;
|
||||
use App\Http\Controllers\V2\Admin\PlanController;
|
||||
use App\Http\Controllers\V2\Admin\Server\GroupController;
|
||||
use App\Http\Controllers\V2\Admin\Server\RouteController;
|
||||
@@ -41,6 +42,17 @@ class AdminRoute
|
||||
$router->post('/testSendMail', [ConfigController::class, 'testSendMail']);
|
||||
});
|
||||
|
||||
// Mail Templates
|
||||
$router->group([
|
||||
'prefix' => 'mail/template'
|
||||
], function ($router) {
|
||||
$router->get('/list', [MailTemplateController::class, 'list']);
|
||||
$router->get('/get', [MailTemplateController::class, 'get']);
|
||||
$router->post('/save', [MailTemplateController::class, 'save']);
|
||||
$router->post('/reset', [MailTemplateController::class, 'reset']);
|
||||
$router->post('/test', [MailTemplateController::class, 'test']);
|
||||
});
|
||||
|
||||
// Plan
|
||||
$router->group([
|
||||
'prefix' => 'plan'
|
||||
|
||||
@@ -14,7 +14,7 @@ class ServerRoute
|
||||
{
|
||||
$router->group([
|
||||
'prefix' => 'server',
|
||||
'middleware' => 'server'
|
||||
'middleware' => 'server.v2'
|
||||
], function ($route) {
|
||||
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
|
||||
$route->post('report', [ServerController::class, 'report']);
|
||||
|
||||
Reference in New Issue
Block a user