1.修复上传漏洞和SQL注入漏洞-增强登录验证功能

This commit is contained in:
2026-06-24 11:47:27 +08:00
parent 546d350f3a
commit 5187330c38
25 changed files with 303 additions and 1462 deletions

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\admin\controller; namespace app\admin\controller;
use ba\Terminal;
use ba\TableManager; use ba\TableManager;
use support\think\Db; use support\think\Db;
use app\admin\model\AdminLog; use app\admin\model\AdminLog;
@@ -166,18 +165,6 @@ class Ajax extends Backend
]); ]);
} }
public function changeTerminalConfig(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Change terminal config'));
if (Terminal::changeTerminalConfig()) {
return $this->success();
}
return $this->error(__('Failed to modify the terminal configuration. Please modify the configuration file manually:%s', ['/config/terminal.php']));
}
public function clearCache(Request $request): Response public function clearCache(Request $request): Response
{ {
$response = $this->initializeBackend($request); $response = $this->initializeBackend($request);
@@ -193,13 +180,4 @@ class Ajax extends Backend
event_trigger('cacheClearAfter'); event_trigger('cacheClearAfter');
return $this->success(__('Cache cleaned~')); return $this->success(__('Cache cleaned~'));
} }
public function terminal(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
(new Terminal())->exec();
return $this->success();
}
} }

View File

@@ -46,14 +46,10 @@ class Index extends Backend
'siteName' => get_sys_config('site_name'), 'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'), 'version' => get_sys_config('version'),
'apiUrl' => $apiUrl, 'apiUrl' => $apiUrl,
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']), 'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types', 'forbidden_suffixes']),
'cdnUrl' => full_url(), 'cdnUrl' => full_url(),
'cdnUrlParams' => config('buildadmin.cdn_url_params'), 'cdnUrlParams' => config('buildadmin.cdn_url_params'),
], ],
'terminal' => [
'phpDevelopmentServer' => str_contains($_SERVER['SERVER_SOFTWARE'] ?? '', 'Development Server'),
'npmPackageManager' => config('terminal.npm_package_manager'),
]
]); ]);
} }

View File

@@ -30,6 +30,7 @@ return [
'File uploaded successfully' => 'File uploaded successfully', 'File uploaded successfully' => 'File uploaded successfully',
'No files were uploaded' => 'No files were uploaded', 'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.', 'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.',
'The uploaded file contains prohibited content' => 'The uploaded file contains prohibited content',
'The uploaded image file is not a valid image' => 'The uploaded image file is not a valid image', 'The uploaded image file is not a valid image' => 'The uploaded image file is not a valid image',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB', 'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.', 'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',

View File

@@ -31,6 +31,7 @@ return [
'File uploaded successfully' => '文件上传成功!', 'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传', 'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许', 'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded file contains prohibited content' => '上传的文件包含禁止的内容',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像', 'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM', 'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!', 'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',

View File

@@ -58,7 +58,7 @@ class Index extends Frontend
'siteName' => get_sys_config('site_name'), 'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'), 'version' => get_sys_config('version'),
'cdnUrl' => full_url(), 'cdnUrl' => full_url(),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']), 'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types', 'forbidden_suffixes']),
'recordNumber' => get_sys_config('record_number'), 'recordNumber' => get_sys_config('record_number'),
'cdnUrlParams' => config('buildadmin.cdn_url_params'), 'cdnUrlParams' => config('buildadmin.cdn_url_params'),
], ],

View File

@@ -21,6 +21,7 @@ return [
'File uploaded successfully' => 'File uploaded successfully!', 'File uploaded successfully' => 'File uploaded successfully!',
'No files were uploaded' => 'No files were uploaded', 'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is not allowed', 'The uploaded file format is not allowed' => 'The uploaded file format is not allowed',
'The uploaded file contains prohibited content' => 'The uploaded file contains prohibited content',
'The uploaded image file is not a valid image' => 'The uploaded image is not valid', 'The uploaded image file is not a valid image' => 'The uploaded image is not valid',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The file is too large (%sMiB), maximum: %sMiB', 'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The file is too large (%sMiB), maximum: %sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No file uploaded or size exceeds server limit', 'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No file uploaded or size exceeds server limit',

View File

@@ -22,6 +22,7 @@ return [
'File uploaded successfully' => 'Fail berjaya dimuat naik!', 'File uploaded successfully' => 'Fail berjaya dimuat naik!',
'No files were uploaded' => 'Tiada fail dimuat naik', 'No files were uploaded' => 'Tiada fail dimuat naik',
'The uploaded file format is not allowed' => 'Format fail yang dimuat naik tidak dibenarkan', 'The uploaded file format is not allowed' => 'Format fail yang dimuat naik tidak dibenarkan',
'The uploaded file contains prohibited content' => 'Fail yang dimuat naik mengandungi kandungan yang dilarang',
'The uploaded image file is not a valid image' => 'Fail imej yang dimuat naik tidak sah', 'The uploaded image file is not a valid image' => 'Fail imej yang dimuat naik tidak sah',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'Fail terlalu besar (%sM), saiz maksimum: %sM', 'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'Fail terlalu besar (%sM), saiz maksimum: %sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'Tiada fail dimuat naik atau saiz melebihi had pelayan', 'No files have been uploaded or the file size exceeds the upload limit of the server' => 'Tiada fail dimuat naik atau saiz melebihi had pelayan',

View File

@@ -20,6 +20,7 @@ return [
'File uploaded successfully' => '文件上传成功!', 'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传', 'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许', 'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded file contains prohibited content' => '上传的文件包含禁止的内容',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像', 'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM', 'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!', 'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',

View File

@@ -59,6 +59,13 @@ class Upload
public function setDriver(string $driver): self public function setDriver(string $driver): self
{ {
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$this->driver['name'] = $driver; $this->driver['name'] = $driver;
return $this; return $this;
} }
@@ -79,12 +86,17 @@ class Upload
protected function resolveDriverClass(string $driver): string|false protected function resolveDriverClass(string $driver): string|false
{ {
if ($this->driver['namespace'] || str_contains($driver, '\\')) { if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . $this->studly($driver); return false;
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
return false;
}
$class = $this->driver['namespace'] . $this->studly($driver);
if (class_exists($class)) { if (class_exists($class)) {
return $class; return $class;
} }
}
return false; return false;
} }
@@ -181,6 +193,10 @@ class Upload
throw new RuntimeException(__('The uploaded file format is not allowed')); throw new RuntimeException(__('The uploaded file format is not allowed'));
} }
$this->assertSafeFileName($this->fileInfo['name']);
$this->assertNoMaliciousContent();
$this->assertImageMime();
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) { if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) {
throw new RuntimeException(__('Topic format error')); throw new RuntimeException(__('Topic format error'));
} }
@@ -257,4 +273,95 @@ class Upload
} }
return $configItem ? explode(',', strtolower((string)$configItem)) : []; return $configItem ? explode(',', strtolower((string)$configItem)) : [];
} }
protected function getForbiddenSuffixes(): array
{
return $this->checkConfig($this->config['forbidden_suffixes'] ?? '');
}
protected function assertSafeFileName(string $fileName): void
{
$forbidden = $this->getForbiddenSuffixes();
if (!$forbidden) {
return;
}
$parts = explode('.', strtolower($fileName));
if (count($parts) < 2) {
return;
}
foreach ($parts as $index => $part) {
if ($part === '') {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($index === count($parts) - 1) {
continue;
}
if (in_array($part, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
$suffix = $parts[count($parts) - 1];
if (in_array($suffix, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
protected function assertNoMaliciousContent(): void
{
$path = $this->file->getPathname();
if (!is_file($path) || !is_readable($path)) {
return;
}
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
$chunk = fread($handle, 8192);
fclose($handle);
if ($chunk === false || $chunk === '') {
return;
}
$patterns = [
'/<\?php/i',
'/<\?=/i',
'/<\?(?!xml)/i',
'/<%[@=]?/i',
'/<script\b/i',
'/\b(eval|assert|shell_exec|system|passthru|proc_open|popen|base64_decode)\s*\(/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $chunk)) {
throw new RuntimeException(__('The uploaded file contains prohibited content'));
}
}
}
protected function assertImageMime(): void
{
$imageSuffixes = ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'];
if (!in_array($this->fileInfo['suffix'], $imageSuffixes, true)) {
return;
}
$path = $this->file->getPathname();
if (!is_file($path)) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$detected = finfo_file($finfo, $path);
finfo_close($finfo);
if (!$detected || !str_starts_with(strtolower($detected), 'image/')) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
}
} }

View File

@@ -99,9 +99,7 @@ Route::get('/admin/ajax/getDatabaseConnectionList', [\app\admin\controller\Ajax:
Route::get('/admin/ajax/getTablePk', [\app\admin\controller\Ajax::class, 'getTablePk']); Route::get('/admin/ajax/getTablePk', [\app\admin\controller\Ajax::class, 'getTablePk']);
Route::get('/admin/ajax/getTableList', [\app\admin\controller\Ajax::class, 'getTableList']); Route::get('/admin/ajax/getTableList', [\app\admin\controller\Ajax::class, 'getTableList']);
Route::get('/admin/ajax/getTableFieldList', [\app\admin\controller\Ajax::class, 'getTableFieldList']); Route::get('/admin/ajax/getTableFieldList', [\app\admin\controller\Ajax::class, 'getTableFieldList']);
Route::post('/admin/ajax/changeTerminalConfig', [\app\admin\controller\Ajax::class, 'changeTerminalConfig']);
Route::post('/admin/ajax/clearCache', [\app\admin\controller\Ajax::class, 'clearCache']); Route::post('/admin/ajax/clearCache', [\app\admin\controller\Ajax::class, 'clearCache']);
Route::add(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], '/admin/ajax/terminal', [\app\admin\controller\Ajax::class, 'terminal']);
// admin/auth/admin // admin/auth/admin
Route::get('/admin/auth/admin/index', [\app\admin\controller\auth\Admin::class, 'index']); Route::get('/admin/auth/admin/index', [\app\admin\controller\auth\Admin::class, 'index']);

View File

@@ -1,70 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin - WEB终端配置
// | Webman 迁移migrate 命令需根据实际迁移工具调整
// +----------------------------------------------------------------------
return [
'npm_package_manager' => 'pnpm',
'commands' => [
'migrate' => [
'run' => [
'cwd' => '',
'command' => 'php vendor/bin/phinx migrate',
'notes' => 'Start the database migration'
],
'rollback' => ['cwd' => '', 'command' => 'php vendor/bin/phinx rollback'],
'breakpoint' => ['cwd' => '', 'command' => 'php vendor/bin/phinx breakpoint'],
],
'version' => [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
],
'web-install' => [
'npm' => ['cwd' => 'web', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web', 'command' => 'ni install'],
],
'nuxt-install' => [
'npm' => ['cwd' => 'web-nuxt', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web-nuxt', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web-nuxt', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web-nuxt', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web-nuxt', 'command' => 'ni install'],
],
'web-build' => [
'npm' => ['cwd' => 'web', 'command' => 'npm run build', 'notes' => 'Start executing the build command of the web project'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm run build', 'notes' => 'Start executing the build command of the web project'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn run build', 'notes' => 'Start executing the build command of the web project'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm run build', 'notes' => 'Start executing the build command of the web project'],
'ni' => ['cwd' => 'web', 'command' => 'nr build', 'notes' => 'Start executing the build command of the web project'],
],
'set-npm-registry' => [
'npm' => 'npm config set registry https://registry.npmjs.org/ && npm config get registry',
'taobao' => 'npm config set registry https://registry.npmmirror.com/ && npm config get registry',
'tencent' => 'npm config set registry https://mirrors.cloud.tencent.com/npm/ && npm config get registry'
],
'set-composer-registry' => [
'composer' => 'composer config --unset repos.packagist',
'aliyun' => 'composer config -g repos.packagist composer https://mirrors.aliyun.com/composer/',
'tencent' => 'composer config -g repos.packagist composer https://mirrors.cloud.tencent.com/composer/',
'huawei' => 'composer config -g repos.packagist composer https://mirrors.huaweicloud.com/repository/php/',
'kkame' => 'composer config -g repos.packagist composer https://packagist.kr',
],
'npx' => [
'prettier' => ['cwd' => 'web', 'command' => 'npx prettier --write %s', 'notes' => 'Start formatting the web project code'],
],
'composer' => [
'update' => ['cwd' => '', 'command' => 'composer update --no-interaction', 'notes' => 'Start installing the composer dependencies']
],
'ping' => [
'baidu' => 'ping baidu.com',
'localhost' => 'ping 127.0.0.1 -n 6',
]
],
];

View File

@@ -16,6 +16,10 @@ return [
* 2. 千万不要允许 php,php5,.htaccess,.user.ini 等可执行或配置文件 * 2. 千万不要允许 php,php5,.htaccess,.user.ini 等可执行或配置文件
* 3. 允许 pdf,ppt,docx 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览 * 3. 允许 pdf,ppt,docx 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览
*/ */
'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3', 'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,wav,mp4,mp3',
'allowed_mime_types' => [], 'allowed_mime_types' => [],
// 禁止上传的后缀(含双后缀检测,如 shell.php.jpg
'forbidden_suffixes' => 'php,php3,php4,php5,php7,php8,phtml,phar,htaccess,htpasswd,user.ini,asp,aspx,jsp,jspx,cgi,pl,sh,bat,cmd,exe,dll,vbs,ps1,com,shtml',
// 允许使用的上传驱动(禁止通过 driver 参数加载任意类)
'allowed_drivers' => 'local',
]; ];

View File

@@ -1,404 +0,0 @@
<?php
namespace ba;
use Throwable;
use support\Response;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use Workerman\Protocols\Http\ServerSentEvents;
use Workerman\Protocols\Http\Response as WorkermanResponse;
/**
* WEB 终端SSE 命令执行)
* 适配 Webman使用 request()->connection、config()
*/
class Terminal
{
protected static ?Terminal $instance = null;
protected string $commandKey = '';
protected array $descriptorsPec = [];
protected $process = false;
protected array $pipes = [];
protected int $procStatusMark = 0;
protected array $procStatusData = [];
protected string $uuid = '';
protected string $extend = '';
protected string $outputFile = '';
protected string $outputContent = '';
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
protected array $flag = [
'link-success' => 'command-link-success',
'exec-success' => 'command-exec-success',
'exec-completed' => 'command-exec-completed',
'exec-error' => 'command-exec-error',
];
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
public function __construct()
{
$request = request();
$this->uuid = $request ? $request->param('uuid', '') : '';
$this->extend = $request ? $request->param('extend', '') : '';
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = config('terminal.commands', []);
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$cwd = $command['cwd'] ?? '';
$root = rtrim(root_path(), DIRECTORY_SEPARATOR . '/');
$command['cwd'] = $root . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $cwd), DIRECTORY_SEPARATOR . '/');
}
$request = request();
if ($request && str_contains($command['command'], '%')) {
$args = $request->param('extend', '');
$args = explode('~~', $args);
$args = array_map('escapeshellarg', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
if (DIRECTORY_SEPARATOR === '\\') {
$command['command'] = 'cmd /c "cd /d ' . str_replace('"', '""', $command['cwd']) . ' && ' . $command['command'] . '"';
$command['cwd'] = root_path();
}
return $command;
}
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$request = request();
$this->commandKey = $request ? $request->param('command', '') : '';
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$procEnv = $this->getProcEnv();
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd'], $procEnv);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
if (isset($this->pipes[0])) {
fclose($this->pipes[0]);
unset($this->pipes[0]);
}
$lastHeartbeat = time();
$heartbeatInterval = 15;
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (strlen(trim($newOutput)) > 0) {
$this->output($newOutput);
$this->outputContent = $contents;
$lastHeartbeat = time();
}
}
if ($this->procStatusMark === 1 && (time() - $lastHeartbeat) >= $heartbeatInterval) {
$this->sendHeartbeat();
$lastHeartbeat = time();
}
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
protected function getProcEnv(): ?array
{
$env = getenv();
if (!is_array($env)) {
return null;
}
$env['CI'] = '1';
$env['FORCE_COLOR'] = '0';
return $env;
}
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();
}
}
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
protected function sendHeartbeat(): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['' => 'keepalive']));
}
@ob_flush();
}
public function outputCallback($data): void
{
}
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
}
return true;
}
public function beforeExecution(): void
{
if ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
public function break(): void
{
throw new TerminalBreakException();
}
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
$request = request();
$oldPackageManager = config('terminal.npm_package_manager');
$newPackageManager = $request ? $request->post('manager', $config['manager'] ?? $oldPackageManager) : $oldPackageManager;
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path('terminal.php');
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
public function finalOutput(string $data): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
public function sendHeader(): void
{
$request = request();
$headers = array_merge($request->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new WorkermanResponse(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}

View File

@@ -1,10 +0,0 @@
<?php
namespace ba;
/**
* 终端中断异常(用于 break() 停止 SSE 流)
*/
class TerminalBreakException extends \Exception
{
}

View File

@@ -7,6 +7,13 @@ namespace ba;
*/ */
class Version class Version
{ {
private const VERSION_COMMANDS = [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
];
/** /**
* 比较两个版本号 * 比较两个版本号
@@ -84,14 +91,13 @@ class Version
*/ */
public static function getCnpmVersion(): string public static function getCnpmVersion(): string
{ {
$execOut = Terminal::getOutputFromProc('version.cnpm'); $execOut = self::getOutputFromProc('cnpm');
if ($execOut) { if ($execOut) {
$preg = '/cnpm@(.+?) \(/is'; $preg = '/cnpm@(.+?) \(/is';
preg_match($preg, $execOut, $result); preg_match($preg, $execOut, $result);
return $result[1] ?? ''; return $result[1] ?? '';
} else {
return '';
} }
return '';
} }
/** /**
@@ -103,9 +109,16 @@ class Version
{ {
if ($name == 'cnpm') { if ($name == 'cnpm') {
return self::getCnpmVersion(); return self::getCnpmVersion();
} elseif (in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) { }
$execOut = Terminal::getOutputFromProc('version.' . $name); if (!in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
if ($execOut) { return '';
}
$execOut = self::getOutputFromProc($name);
if (!$execOut) {
return '';
}
if (strripos($execOut, 'npm WARN') !== false) { if (strripos($execOut, 'npm WARN') !== false) {
$preg = '/\d+(\.\d+){0,2}/'; $preg = '/\d+(\.\d+){0,2}/';
preg_match($preg, $execOut, $matches); preg_match($preg, $execOut, $matches);
@@ -113,16 +126,52 @@ class Version
return $matches[0]; return $matches[0];
} }
} }
$execOut = preg_split('/\r\n|\r|\n/', $execOut); $execOut = preg_split('/\r\n|\r|\n/', $execOut);
for ($i = 0; $i < 2; $i++) { for ($i = 0; $i < 2; $i++) {
if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) { if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) {
return $execOut[$i]; return $execOut[$i];
} }
} }
} else {
return ''; return '';
} }
private static function getOutputFromProc(string $name): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
} }
$command = self::VERSION_COMMANDS[$name] ?? '';
if (!$command) {
return false;
}
if (DIRECTORY_SEPARATOR === '\\') {
$command = 'cmd /c "' . $command . '"';
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command, $descriptorsPec, $pipes, root_path(), null);
if (!is_resource($process)) {
return ''; return '';
} }
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
private static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
} }

View File

@@ -24,8 +24,6 @@ export const getTablePkUrl = '/admin/ajax/getTablePk'
export const getTableListUrl = '/admin/ajax/getTableList' export const getTableListUrl = '/admin/ajax/getTableList'
export const getTableFieldListUrl = '/admin/ajax/getTableFieldList' export const getTableFieldListUrl = '/admin/ajax/getTableFieldList'
export const getDatabaseConnectionListUrl = '/admin/ajax/getDatabaseConnectionList' export const getDatabaseConnectionListUrl = '/admin/ajax/getDatabaseConnectionList'
export const terminalUrl = adminBaseRoutePath + '/ajax/terminal'
export const changeTerminalConfigUrl = '/admin/ajax/changeTerminalConfig'
export const clearCacheUrl = '/admin/ajax/clearCache' export const clearCacheUrl = '/admin/ajax/clearCache'
// 公共 // 公共
@@ -177,27 +175,6 @@ export function postClearCache(type: string) {
) )
} }
/**
* 构建命令执行窗口url
*/
export function buildTerminalUrl(commandKey: string, uuid: string, extend: string) {
const adminInfo = useAdminInfo()
return (
getUrl() + terminalUrl + '?command=' + commandKey + '&uuid=' + uuid + '&extend=' + extend + '&batoken=' + adminInfo.getToken() + '&server=1'
)
}
/**
* 请求修改终端配置
*/
export function postChangeTerminalConfig(data: { manager?: string; port?: string }) {
return createAxios({
url: changeTerminalConfigUrl,
method: 'POST',
data: data,
})
}
/** /**
* 远程下拉框数据获取 * 远程下拉框数据获取
*/ */

View File

@@ -1,439 +0,0 @@
<template>
<div>
<el-dialog v-model="terminal.state.show" :title="t('terminal.Terminal')" class="ba-terminal-dialog main-dialog">
<el-scrollbar ref="terminalScrollbarRef" :max-height="500" class="terminal-scrollbar">
<el-alert
class="terminal-warning-alert"
v-if="!terminal.state.phpDevelopmentServer"
:title="t('terminal.The current terminal is not running under the installation service, and some commands may not be executed')"
type="error"
/>
<el-timeline class="terminal-timeline" v-if="terminal.state.taskList.length">
<el-timeline-item
v-for="(item, idx) in terminal.state.taskList"
:key="idx"
class="task-item"
:class="'task-status-' + item.status"
:type="getTaskStatus(item.status)['statusType']"
center
:timestamp="item.createTime"
placement="top"
>
<el-card>
<div>
<el-tag :type="getTaskStatus(item.status)['statusType']">{{ getTaskStatus(item.status)['statusText'] }}</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="(item.status == taskStatus.Failed || item.status == taskStatus.Unknown) && item.blockOnFailure"
type="warning"
>
{{ t('terminal.Failure to execute this command will block the execution of the queue') }}
</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="item.status == taskStatus.Executing || item.status == taskStatus.Connecting"
type="danger"
>
{{ t('terminal.Do not refresh the browser') }}
</el-tag>
<span class="command">{{ item.command }}</span>
<div class="task-opt">
<el-button
:title="t('Retry')"
v-if="item.status == taskStatus.Failed || item.status == taskStatus.Unknown"
size="small"
v-blur
type="warning"
icon="el-icon-RefreshRight"
circle
@click="terminal.retryTask(idx)"
/>
<el-button
@click="terminal.delTask(idx)"
:title="t('Delete')"
size="small"
v-blur
type="danger"
icon="el-icon-Delete"
circle
/>
</div>
</div>
<template v-if="item.status != taskStatus.Waiting">
<div
v-if="item.status != taskStatus.Connecting && item.status != taskStatus.Executing"
@click="terminal.setTaskShowMessage(idx)"
class="toggle-message-display"
>
<span>{{ t('terminal.Command run log') }}</span>
<Icon :name="item.showMessage ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'" size="16" color="#909399" />
</div>
<div
v-if="
item.status == taskStatus.Connecting ||
item.status == taskStatus.Executing ||
(item.status > taskStatus.Executing && item.showMessage)
"
class="exec-message"
:class="'exec-message-' + item.uuid"
>
<pre v-for="(msg, index) in item.message" :key="index" class="message-item">{{ msg }}</pre>
</div>
</template>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else :image-size="80" :description="t('terminal.No mission yet')" />
</el-scrollbar>
<div class="terminal-buttons">
<el-button class="terminal-menu-item" icon="el-icon-MagicStick" v-blur @click="addTerminalTask('test', true, false)">
{{ t('terminal.Test command') }}
</el-button>
<el-dropdown class="terminal-menu-item">
<el-button icon="el-icon-Download" v-blur>
{{ t('terminal.Install dependent packages') }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="addTerminalTask('web-install', true)" v-if="terminal.state.packageManager != 'none'">
{{ terminal.state.packageManager }} run install
</el-dropdown-item>
<el-dropdown-item @click="addTerminalTask('composer.update', false)">composer update</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="terminal-menu-item" icon="el-icon-Sell" v-blur @click="webBuild()">{{ t('terminal.Republish') }}</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Delete" v-blur @click="terminal.clearSuccessTask()">
{{ t('terminal.Clean up task list') }}
</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Tools" v-blur @click="terminal.toggleConfigDialog()">
{{ t('terminal.Terminal settings') }}
</el-button>
</div>
</el-dialog>
<el-dialog
@close="terminal.toggleConfigDialog(false)"
:model-value="terminal.state.showConfig"
class="ba-terminal-dialog"
:title="t('terminal.Terminal settings')"
>
<el-form label-position="left" label-width="140">
<FormItem
:label="'NPM ' + t('terminal.Source')"
:model-value="terminal.state.npmRegistry"
:key="terminal.state.npmRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'npm'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('npm'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'npm'),
}"
/>
<FormItem
:label="'Composer ' + t('terminal.Source')"
:model-value="terminal.state.composerRegistry"
:key="terminal.state.composerRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'composer'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('composer'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'composer'),
}"
/>
<FormItem
:label="t('terminal.NPM package manager')"
:model-value="terminal.state.packageManager"
v-loading="state.packageManagerLoading"
type="select"
:input-attr="{
border: true,
content: { npm: 'NPM', cnpm: 'CNPM', pnpm: 'PNPM', yarn: 'YARN', ni: 'NI', none: t('terminal.Manual execution') },
teleported: false,
onChange: (val: string) => changePackageManager(val),
}"
:tip="t('terminal.NPM package manager tip')"
/>
<FormItem
:label="t('terminal.Clear successful task')"
:model-value="terminal.state.automaticCleanupTask"
type="radio"
:input-attr="{
border: true,
content: { '0': t('Disable'), '1': t('Enable') },
onChange: terminal.changeAutomaticCleanupTask,
}"
:tip="t('terminal.Clear successful task tip')"
/>
</el-form>
<div class="config-buttons">
<el-button @click="terminal.toggleConfigDialog(false)">{{ t('terminal.Back to terminal') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { TimelineItemProps } from 'element-plus'
import { ElMessageBox, ElScrollbar } from 'element-plus'
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { postChangeTerminalConfig } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { changeListenDirtyFileSwitch } from '/@/utils/vite'
type SourceType = 'npm' | 'composer'
const { t } = useI18n()
const terminal = useTerminal()
const terminalScrollbarRef = useTemplateRef('terminalScrollbarRef')
const state = reactive({
registryLoading: false,
registryLoadingType: 'npm',
packageManagerLoading: false,
})
const getTaskStatus = (status: number) => {
let statusText = t('terminal.unknown')
let statusType: TimelineItemProps['type'] = 'info'
switch (status) {
case taskStatus.Waiting:
statusText = t('terminal.Waiting for execution')
statusType = 'info'
break
case taskStatus.Connecting:
statusText = t('terminal.Connecting')
statusType = 'warning'
break
case taskStatus.Executing:
statusText = t('terminal.Executing')
statusType = 'warning'
break
case taskStatus.Success:
statusText = t('terminal.Successful execution')
statusType = 'success'
break
case taskStatus.Failed:
statusText = t('terminal.Execution failed')
statusType = 'danger'
break
case taskStatus.Unknown:
statusText = t('terminal.Unknown execution result')
statusType = 'danger'
break
}
return {
statusText: statusText,
statusType: statusType,
}
}
const addTerminalTask = (command: string, pm: boolean, blockOnFailure = true, extend = '', callback: Function = () => {}) => {
if (pm) {
terminal.addTaskPM(command, blockOnFailure, extend, callback)
} else {
terminal.addTask(command, blockOnFailure, extend, callback)
}
// 任务列表滚动条滚动到底部
nextTick(() => {
if (terminalScrollbarRef.value && terminalScrollbarRef.value.wrapRef) {
terminalScrollbarRef.value.setScrollTop(terminalScrollbarRef.value.wrapRef.scrollHeight)
}
})
}
const webBuild = () => {
ElMessageBox.confirm(t('terminal.Are you sure you want to republish?'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
changeListenDirtyFileSwitch(false)
addTerminalTask('web-build', true, true, '', () => {
changeListenDirtyFileSwitch(true)
})
})
}
const changePackageManager = (val: string) => {
state.packageManagerLoading = true
postChangeTerminalConfig({ manager: val })
.then((res) => {
if (res.code == 1) {
terminal.changePackageManager(val)
}
})
.finally(() => {
state.packageManagerLoading = false
})
}
const changeRegistry = (val: string, type: SourceType) => {
const oldVal = type == 'npm' ? terminal.state.npmRegistry : terminal.state.composerRegistry
terminal.changeRegistry(val, type)
state.registryLoading = true
state.registryLoadingType = type
terminal.addTask(`set-${type}-registry.${val}`, false, '', (res: taskStatus) => {
state.registryLoading = false
if (res == taskStatus.Failed || res == taskStatus.Unknown) {
ElMessageBox.confirm(t('terminal.Failed to modify the source command, Please try again manually'), t('Reminder'), {
confirmButtonText: t('Confirm'),
showCancelButton: false,
type: 'warning',
}).then(() => {
terminal.changeRegistry(oldVal, type)
})
}
})
}
const getSourceContent = (type: SourceType) => {
let content: anyObj = {}
if (type == 'npm') {
content = { npm: 'npm', taobao: 'taobao', tencent: 'tencent' }
} else if (type == 'composer') {
content = {
composer: 'composer',
huawei: 'huawei',
aliyun: 'aliyun',
tencent: 'tencent',
kkame: 'kkame',
}
}
// 如果值为 unknown则 unknown 选项
if (terminal.state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] == 'unknown') {
content.unknown = t('Unknown')
}
return content
}
onMounted(() => {
terminal.init()
})
</script>
<style scoped lang="scss">
.terminal-warning-alert {
margin: 0 0 20px 0;
}
.terminal-timeline {
padding: 0 15px;
}
.command {
font-size: var(--el-font-size-large);
font-weight: bold;
margin-left: 10px;
}
.exec-message {
color: var(--ba-bg-color-overlay);
font-size: 12px;
line-height: 16px;
padding: 6px;
background-color: #424251;
margin-top: 10px;
min-height: 30px;
max-height: 200px;
overflow: auto;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: var(--ba-bg-color);
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #909399;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.exec-message {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
.toggle-message-display {
padding-top: 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.task-opt {
display: none;
float: right;
}
.task-item.task-status-0:hover,
.task-item.task-status-3:hover,
.task-item.task-status-4:hover,
.task-item.task-status-5:hover {
.task-opt {
display: inline;
}
}
.block-on-failure-tag {
margin-left: 10px;
}
.terminal-menu-item {
margin-bottom: 12px;
}
.terminal-menu-item + .terminal-menu-item {
margin-left: 12px;
margin-bottom: 12px;
}
.terminal-buttons {
display: block;
width: fit-content;
margin: 0 auto;
padding-top: 12px;
}
.config-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 20px;
padding-right: 20px;
}
:deep(.main-dialog) {
--el-dialog-padding-primary: 16px 16px 0 16px;
.el-dialog__body {
margin-top: 16px;
}
}
:deep(.ba-terminal-dialog) {
--el-dialog-width: 46% !important;
.el-loading-spinner {
--el-loading-spinner-size: 20px;
}
}
@media screen and (max-width: 768px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 540px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 94% !important;
}
}
</style>

View File

@@ -71,13 +71,6 @@
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" v-else name="el-icon-FullScreen" size="18" /> <Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" v-else name="el-icon-FullScreen" size="18" />
</div> </div>
<!-- 终端 - 仅超管 -->
<div v-if="adminInfo.super" @click="terminal.toggle()" class="nav-menu-item pt2">
<el-badge :is-dot="terminal.state.showDot">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-terminal" size="26" />
</el-badge>
</div>
<!-- 清理缓存 - 仅超管 --> <!-- 清理缓存 - 仅超管 -->
<el-dropdown <el-dropdown
v-if="adminInfo.super" v-if="adminInfo.super"
@@ -140,7 +133,6 @@
</div> </div>
<Config /> <Config />
<Terminal />
</div> </div>
</template> </template>
@@ -152,14 +144,12 @@ import { useI18n } from 'vue-i18n'
import Config from './config.vue' import Config from './config.vue'
import { logout } from '/@/api/backend/index' import { logout } from '/@/api/backend/index'
import { postClearCache } from '/@/api/common' import { postClearCache } from '/@/api/common'
import Terminal from '/@/components/terminal/index.vue'
import { editDefaultLang } from '/@/lang' import { editDefaultLang } from '/@/lang'
import router from '/@/router' import router from '/@/router'
import { useAdminInfo } from '/@/stores/adminInfo' import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { ADMIN_INFO, BA_ACCOUNT } from '/@/stores/constant/cacheKey' import { ADMIN_INFO, BA_ACCOUNT } from '/@/stores/constant/cacheKey'
import { useSiteConfig } from '/@/stores/siteConfig' import { useSiteConfig } from '/@/stores/siteConfig'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl, timeFormat } from '/@/utils/common' import { fullUrl, timeFormat } from '/@/utils/common'
import { routePush } from '/@/utils/router' import { routePush } from '/@/utils/router'
import { Local, Session } from '/@/utils/storage' import { Local, Session } from '/@/utils/storage'
@@ -169,7 +159,6 @@ const { t } = useI18n()
const adminInfo = useAdminInfo() const adminInfo = useAdminInfo()
const configStore = useConfig() const configStore = useConfig()
const terminal = useTerminal()
const siteConfig = useSiteConfig() const siteConfig = useSiteConfig()
const reloadHotServerPopover = useTemplateRef('reloadHotServerPopover') const reloadHotServerPopover = useTemplateRef('reloadHotServerPopover')

View File

@@ -6,7 +6,6 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs' import { useNavTabs } from '/@/stores/navTabs'
import { useTerminal } from '/@/stores/terminal'
import { useSiteConfig } from '/@/stores/siteConfig' import { useSiteConfig } from '/@/stores/siteConfig'
import { useAdminInfo } from '/@/stores/adminInfo' import { useAdminInfo } from '/@/stores/adminInfo'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
@@ -29,7 +28,6 @@ defineOptions({
components: { Default, Classic, Streamline, Double }, components: { Default, Classic, Streamline, Double },
}) })
const terminal = useTerminal()
const navTabs = useNavTabs() const navTabs = useNavTabs()
const config = useConfig() const config = useConfig()
const route = useRoute() const route = useRoute()
@@ -59,8 +57,6 @@ const init = () => {
index() index()
.then((res) => { .then((res) => {
siteConfig.dataFill(res.data.siteConfig) siteConfig.dataFill(res.data.siteConfig)
terminal.changePackageManager(res.data.terminal.npmPackageManager)
terminal.changePHPDevelopmentServer(res.data.terminal.phpDevelopmentServer)
siteConfig.setInitialize(true) siteConfig.setInitialize(true)
if (!isEmpty(res.data.adminInfo)) { if (!isEmpty(res.data.adminInfo)) {

View File

@@ -9,8 +9,6 @@ export const ADMIN_INFO = 'adminInfo'
export const STORE_CONFIG = 'storeConfig_v2' export const STORE_CONFIG = 'storeConfig_v2'
// 后台标签页 // 后台标签页
export const STORE_TAB_VIEW_CONFIG = 'storeTabViewConfig' export const STORE_TAB_VIEW_CONFIG = 'storeTabViewConfig'
// 终端
export const STORE_TERMINAL = 'storeTerminal'
// 工作时间 // 工作时间
export const WORKING_TIME = 'workingTime' export const WORKING_TIME = 'workingTime'

View File

@@ -1,8 +0,0 @@
export const enum taskStatus {
Waiting,
Connecting,
Executing,
Success,
Failed,
Unknown,
}

View File

@@ -134,48 +134,6 @@ export interface UserInfo {
refresh_token: string refresh_token: string
} }
export interface TaskItem {
// 任务唯一标识
uuid: string
// 创建时间
createTime: string
// 状态
status: number
// 命令
command: string
// 命令执行日志
message: string[]
// 显示命令执行日志
showMessage: boolean
// 失败阻断后续命令执行
blockOnFailure: boolean
// 扩展信息,自动发送到后台
extend: string
// 执行结果回调
callback: Function
}
export interface Terminal {
// 显示终端窗口
show: boolean
// 在后台终端按钮上显示一个红点
showDot: boolean
// 任务列表
taskList: TaskItem[]
// 包管理器
packageManager: string
// 显示终端设置窗口
showConfig: boolean
// 开始任务时自动清理已完成任务
automaticCleanupTask: string
// PHP 开发服务环境
phpDevelopmentServer: boolean
// NPM 源
npmRegistry: string
// composer 源
composerRegistry: string
}
export interface SiteConfig { export interface SiteConfig {
// 站点名称 // 站点名称
siteName: string siteName: string

View File

@@ -1,292 +0,0 @@
import { ElNotification } from 'element-plus'
import { defineStore } from 'pinia'
import { nextTick, reactive } from 'vue'
import { buildTerminalUrl } from '/@/api/common'
import { i18n } from '/@/lang/index'
import { STORE_TERMINAL } from '/@/stores/constant/cacheKey'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { Terminal } from '/@/stores/interface/index'
import { timeFormat } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
export const useTerminal = defineStore(
'terminal',
() => {
const state: Terminal = reactive({
show: false,
showDot: false,
taskList: [],
packageManager: 'pnpm',
showConfig: false,
automaticCleanupTask: '1',
phpDevelopmentServer: false,
npmRegistry: 'unknown',
composerRegistry: 'unknown',
})
function init() {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
state.taskList[key].status = taskStatus.Unknown
}
}
}
function toggle(val = !state.show) {
state.show = val
if (val) {
toggleDot(false)
}
}
function toggleDot(val = !state.showDot) {
state.showDot = val
}
function toggleConfigDialog(val = !state.showConfig) {
toggle(!val)
state.showConfig = val
}
function changeRegistry(val: string, type: 'npm' | 'composer') {
state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] = val
}
function changePackageManager(val: string) {
state.packageManager = val
}
function changePHPDevelopmentServer(val: boolean) {
state.phpDevelopmentServer = val
}
function changeAutomaticCleanupTask(val: '0' | '1') {
state.automaticCleanupTask = val
}
function setTaskStatus(idx: number, status: number) {
state.taskList[idx].status = status
if ((status == taskStatus.Failed || status == taskStatus.Unknown) && state.taskList[idx].blockOnFailure) {
setTaskShowMessage(idx, true)
}
}
function taskCompleted(idx: number) {
// 命令执行完毕,重新打开热更新
openHotUpdate('terminal')
if (typeof state.taskList[idx].callback != 'function') return
const status = state.taskList[idx].status
if (status == taskStatus.Failed || status == taskStatus.Unknown) {
state.taskList[idx].callback(taskStatus.Failed)
} else if (status == taskStatus.Success) {
state.taskList[idx].callback(taskStatus.Success)
}
}
function setTaskShowMessage(idx: number, val = !state.taskList[idx].showMessage) {
state.taskList[idx].showMessage = val
}
function addTaskMessage(idx: number, message: string) {
if (!state.show) toggleDot(true)
state.taskList[idx].message = state.taskList[idx].message.concat(message)
nextTick(() => {
execMessageScrollbarKeep(state.taskList[idx].uuid)
})
}
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({
uuid: uuid(),
createTime: timeFormat(),
status: taskStatus.Waiting,
command: command,
message: [],
showMessage: false,
blockOnFailure: blockOnFailure,
extend: extend,
callback: callback,
})
// 清理任务列表
if (parseInt(state.automaticCleanupTask) === 1) {
clearSuccessTask()
}
// 检查是否有已经失败的任务
if (state.show === false) {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
ElNotification({
type: 'error',
message: i18n.global.t('terminal.Newly added tasks will never start because they are blocked by failed tasks'),
zIndex: SYSTEM_ZINDEX,
})
break
}
}
}
startTask()
}
function addTaskPM(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
addTask(command + '.' + state.packageManager, blockOnFailure, extend, callback)
}
function delTask(idx: number) {
if (state.taskList[idx].status != taskStatus.Connecting && state.taskList[idx].status != taskStatus.Executing) {
state.taskList.splice(idx, 1)
}
startTask()
}
function startTask() {
let taskKey = null
// 寻找可以开始执行的命令
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Waiting) {
taskKey = parseInt(key)
break
}
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
break
}
if (state.taskList[key].status == taskStatus.Success) {
continue
}
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
if (state.taskList[key].blockOnFailure) {
break
} else {
continue
}
}
}
if (taskKey !== null) {
setTaskStatus(taskKey, taskStatus.Connecting)
startEventSource(taskKey)
}
}
function startEventSource(taskKey: number) {
// 命令执行期间禁用热更新
closeHotUpdate('terminal')
window.eventSource = new EventSource(
buildTerminalUrl(state.taskList[taskKey].command, state.taskList[taskKey].uuid, state.taskList[taskKey].extend)
)
window.eventSource.onmessage = function (e) {
const data = JSON.parse(e.data)
if (!data || !data.data) {
return
}
const taskIdx = findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) {
return
}
if (data.data == 'command-exec-error') {
setTaskStatus(taskIdx, taskStatus.Failed)
window.eventSource.close()
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-exec-completed') {
window.eventSource.close()
if (state.taskList[taskIdx].status != taskStatus.Success) {
setTaskStatus(taskIdx, taskStatus.Failed)
}
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-link-success') {
setTaskStatus(taskIdx, taskStatus.Executing)
} else if (data.data == 'command-exec-success') {
setTaskStatus(taskIdx, taskStatus.Success)
} else {
addTaskMessage(taskIdx, data.data)
}
}
window.eventSource.onerror = function () {
window.eventSource.close()
const taskIdx = findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
setTaskStatus(taskIdx, taskStatus.Failed)
taskCompleted(taskIdx)
}
}
function retryTask(idx: number) {
state.taskList[idx].message = []
setTaskStatus(idx, taskStatus.Waiting)
startTask()
}
function clearSuccessTask() {
state.taskList = state.taskList.filter((item) => item.status != taskStatus.Success)
}
function findTaskIdxFromUuid(uuid: string) {
for (const key in state.taskList) {
if (state.taskList[key].uuid == uuid) {
return parseInt(key)
}
}
return false
}
function findTaskIdxFromGuess(idx: number) {
if (!state.taskList[idx]) {
let taskKey = -1
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
taskKey = parseInt(key)
}
}
return taskKey === -1 ? false : taskKey
} else {
return idx
}
}
function execMessageScrollbarKeep(uuid: string) {
const execMessageEl = document.querySelector('.exec-message-' + uuid) as Element
if (execMessageEl && execMessageEl.scrollHeight) {
execMessageEl.scrollTop = execMessageEl.scrollHeight
}
}
return {
state,
init,
toggle,
toggleDot,
setTaskStatus,
setTaskShowMessage,
addTaskMessage,
addTask,
addTaskPM,
delTask,
startTask,
retryTask,
clearSuccessTask,
toggleConfigDialog,
changeRegistry,
changePackageManager,
changePHPDevelopmentServer,
changeAutomaticCleanupTask,
}
},
{
persist: {
key: STORE_TERMINAL,
pick: ['state.showDot', 'state.taskList', 'state.automaticCleanupTask', 'state.npmRegistry', 'state.composerRegistry'],
},
}
)

View File

@@ -287,7 +287,25 @@ export const checkFileMimetype = (fileName: string, fileType: string) => {
? siteConfig.upload.allowedMimeTypes ? siteConfig.upload.allowedMimeTypes
: siteConfig.upload.allowedMimeTypes.toLowerCase().split(',') : siteConfig.upload.allowedMimeTypes.toLowerCase().split(',')
const forbiddenSuffixes = siteConfig.upload.forbiddenSuffixes
? isArray(siteConfig.upload.forbiddenSuffixes)
? siteConfig.upload.forbiddenSuffixes
: siteConfig.upload.forbiddenSuffixes.toLowerCase().split(',')
: []
const nameParts = fileName.toLowerCase().split('.')
if (nameParts.length > 1) {
for (let i = 0; i < nameParts.length - 1; i++) {
if (forbiddenSuffixes.includes(nameParts[i])) {
return false
}
}
}
const fileSuffix = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() const fileSuffix = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
if (forbiddenSuffixes.includes(fileSuffix)) {
return false
}
if (allowedSuffixes.includes(fileSuffix) || allowedSuffixes.includes('.' + fileSuffix)) { if (allowedSuffixes.includes(fileSuffix) || allowedSuffixes.includes('.' + fileSuffix)) {
return true return true
} }

View File

@@ -691,7 +691,6 @@ import { getDatabaseConnectionListUrl, getTableFieldList, getTableListUrl } from
import BaInput from '/@/components/baInput/index.vue' import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue' import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { useTerminal } from '/@/stores/terminal'
import { getArrayKey } from '/@/utils/common' import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random' import { uuid } from '/@/utils/random'
import { buildValidatorData, regularVarName } from '/@/utils/validate' import { buildValidatorData, regularVarName } from '/@/utils/validate'
@@ -702,7 +701,6 @@ import { changeStep, state as crudState, designTypes, fieldItem, getTableAttr, t
let nameRepeatCount = 1 let nameRepeatCount = 1
const { t } = useI18n() const { t } = useI18n()
const config = useConfig() const config = useConfig()
const terminal = useTerminal()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const tabsRefs = useTemplateRefsList<HTMLElement>() const tabsRefs = useTemplateRefsList<HTMLElement>()
const designWindowRef = useTemplateRef('designWindowRef') const designWindowRef = useTemplateRef('designWindowRef')
@@ -1161,13 +1159,7 @@ const startGenerate = () => {
}) })
.then((res) => { .then((res) => {
const callback = () => { const callback = () => {
const webViewsDir = state.table.webViewsDir.replace(/^web/, '.')
terminal.toggle(true)
terminal.addTask('npx.prettier', false, webViewsDir, () => {
terminal.toggle(false)
terminal.toggleDot(true)
nextTick(() => { nextTick(() => {
// 要求 Vite 服务端重启
if (import.meta.hot) { if (import.meta.hot) {
reloadServer('crud') reloadServer('crud')
} else { } else {
@@ -1177,7 +1169,6 @@ const startGenerate = () => {
}) })
} }
}) })
})
} }
if ((state.sync > 0 && config.crud.syncedUpdate === 'yes') || (state.sync == 0 && config.crud.syncType == 'automatic')) { if ((state.sync > 0 && config.crud.syncedUpdate === 'yes') || (state.sync == 0 && config.crud.syncType == 'automatic')) {