Добавить редактор конфигурационных файлов XLX
Этот коммит содержится в:
@@ -13,6 +13,7 @@
|
||||
- установка и управление `xlxd`;
|
||||
- главный пользовательский дашборд `/`;
|
||||
- админка настроек `xlxd` `/admin`;
|
||||
- графический редактор `/xlxd/xlxd.blacklist`, `xlxd.whitelist`, `xlxd.interlink`, `xlxd.terminal`;
|
||||
- экспорт активных пользователей для будущего access gateway.
|
||||
|
||||
## Компоненты
|
||||
|
||||
@@ -31,3 +31,22 @@
|
||||
- URL репозитория `xlxd`.
|
||||
|
||||
При сохранении с флагом `apply` API пытается обновить `/etc/xlx/xlxd.env`. Если PHP работает без прав записи в `/etc/xlx`, настройки все равно сохранятся в БД, а в админке будет виден готовый env-preview.
|
||||
|
||||
## Редактор файлов XLX
|
||||
|
||||
В админке есть отдельный блок для редактирования:
|
||||
|
||||
- `/xlxd/xlxd.blacklist`;
|
||||
- `/xlxd/xlxd.whitelist`;
|
||||
- `/xlxd/xlxd.interlink`;
|
||||
- `/xlxd/xlxd.terminal`.
|
||||
|
||||
API открывает только эти четыре файла. Произвольный путь передать нельзя.
|
||||
|
||||
После изменения файлов при необходимости перезапусти reflector:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart xlxd
|
||||
```
|
||||
|
||||
Установщик выдает группе `www-data` права записи на эти файлы, чтобы сохранение работало из веб-интерфейса.
|
||||
|
||||
@@ -5,6 +5,15 @@ const adminResult = document.querySelector('#adminResult');
|
||||
const loadButton = document.querySelector('#loadSettings');
|
||||
const saveButton = document.querySelector('#saveSettings');
|
||||
const applyButton = document.querySelector('#applySettings');
|
||||
const loadConfigFilesButton = document.querySelector('#loadConfigFiles');
|
||||
const saveConfigFileButton = document.querySelector('#saveConfigFile');
|
||||
const reloadConfigFileButton = document.querySelector('#reloadConfigFile');
|
||||
const fileTabs = document.querySelector('#fileTabs');
|
||||
const fileMeta = document.querySelector('#fileMeta');
|
||||
const fileEditor = document.querySelector('#fileEditor');
|
||||
const fileResult = document.querySelector('#fileResult');
|
||||
let configFiles = [];
|
||||
let selectedFileKey = null;
|
||||
|
||||
function token() {
|
||||
return tokenInput.value.trim();
|
||||
@@ -67,6 +76,105 @@ loadButton?.addEventListener('click', loadSettings);
|
||||
saveButton?.addEventListener('click', () => saveSettings(false));
|
||||
applyButton?.addEventListener('click', () => saveSettings(true));
|
||||
|
||||
function selectedFile() {
|
||||
return configFiles.find((file) => file.key === selectedFileKey) || null;
|
||||
}
|
||||
|
||||
function setFileResult(text) {
|
||||
fileResult.textContent = text;
|
||||
}
|
||||
|
||||
function renderFileTabs() {
|
||||
fileTabs.innerHTML = '';
|
||||
for (const file of configFiles) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `file-tab${file.key === selectedFileKey ? ' active' : ''}`;
|
||||
button.textContent = file.name;
|
||||
button.addEventListener('click', () => selectFile(file.key));
|
||||
fileTabs.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileMeta(file) {
|
||||
if (!file) {
|
||||
fileMeta.innerHTML = '<span>Файл не выбран</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const writable = file.writable ? 'доступен для записи' : 'нет прав на запись';
|
||||
const exists = file.exists ? 'существует' : 'будет создан при сохранении';
|
||||
fileMeta.innerHTML = `
|
||||
<span>${file.path}</span>
|
||||
<span>${exists}</span>
|
||||
<span>${writable}</span>
|
||||
<span>${file.updated_at || 'без даты'}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function selectFile(key) {
|
||||
selectedFileKey = key;
|
||||
const file = selectedFile();
|
||||
renderFileTabs();
|
||||
renderFileMeta(file);
|
||||
fileEditor.value = file?.content || '';
|
||||
setFileResult(file ? file.description : '');
|
||||
}
|
||||
|
||||
async function loadConfigFiles() {
|
||||
setFileResult('Загружаем файлы XLX...');
|
||||
const response = await fetch('/api/admin/xlxd/config-files', {
|
||||
headers: { 'X-Admin-Token': token() },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
setFileResult(payload.error || 'Ошибка загрузки файлов');
|
||||
return;
|
||||
}
|
||||
|
||||
configFiles = payload.data;
|
||||
selectedFileKey = selectedFileKey || configFiles[0]?.key || null;
|
||||
renderFileTabs();
|
||||
selectFile(selectedFileKey);
|
||||
}
|
||||
|
||||
async function saveConfigFile() {
|
||||
const file = selectedFile();
|
||||
if (!file) {
|
||||
setFileResult('Сначала выбери файл.');
|
||||
return;
|
||||
}
|
||||
|
||||
setFileResult(`Сохраняем ${file.filename}...`);
|
||||
const response = await fetch('/api/admin/xlxd/config-files', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Token': token(),
|
||||
},
|
||||
body: JSON.stringify({ file: file.key, content: fileEditor.value }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
setFileResult(payload.error || 'Ошибка сохранения файла');
|
||||
return;
|
||||
}
|
||||
|
||||
configFiles = configFiles.map((item) => item.key === payload.data.key ? payload.data : item);
|
||||
selectFile(payload.data.key);
|
||||
setFileResult(`${payload.data.filename} сохранен.`);
|
||||
}
|
||||
|
||||
loadConfigFilesButton?.addEventListener('click', loadConfigFiles);
|
||||
saveConfigFileButton?.addEventListener('click', saveConfigFile);
|
||||
reloadConfigFileButton?.addEventListener('click', () => {
|
||||
const file = selectedFile();
|
||||
if (file) {
|
||||
fileEditor.value = file.content || '';
|
||||
setFileResult('Изменения в редакторе отменены.');
|
||||
}
|
||||
});
|
||||
|
||||
form?.addEventListener('input', () => {
|
||||
const data = formData();
|
||||
const lines = {
|
||||
|
||||
@@ -207,7 +207,22 @@ input {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #06070b;
|
||||
color: var(--text);
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -230,14 +245,81 @@ input:focus {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
max-width: 760px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.file-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-tab {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tab.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-meta span {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.panel.split,
|
||||
.settings-form {
|
||||
.settings-form,
|
||||
.config-editor-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-editor-head {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Xlx\Domain\AccessExportService;
|
||||
use Xlx\Domain\IdAllocator;
|
||||
use Xlx\Domain\PaymentService;
|
||||
use Xlx\Domain\RegistrationService;
|
||||
use Xlx\Domain\XlxdConfigFileService;
|
||||
use Xlx\Domain\XlxdSettingsService;
|
||||
use Xlx\Domain\YooKassaClient;
|
||||
use Xlx\Support\Database;
|
||||
@@ -117,6 +118,18 @@ try {
|
||||
Response::json(['ok' => true, 'data' => $service->save($pdo, $input['settings'] ?? [], (bool) ($input['apply'] ?? false))]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/admin/xlxd/config-files') {
|
||||
Security::requireAdminToken($config);
|
||||
Response::json(['ok' => true, 'data' => (new XlxdConfigFileService($config))->list()]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/admin/xlxd/config-files') {
|
||||
Security::requireAdminToken($config);
|
||||
$input = Input::json();
|
||||
$service = new XlxdConfigFileService($config);
|
||||
Response::json(['ok' => true, 'data' => $service->save((string) ($input['file'] ?? ''), (string) ($input['content'] ?? ''))]);
|
||||
}
|
||||
|
||||
Response::error('Route not found.', 404);
|
||||
} catch (Throwable $throwable) {
|
||||
Response::error($throwable->getMessage(), 400);
|
||||
|
||||
@@ -81,6 +81,33 @@
|
||||
<pre class="result" id="adminResult"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel config-editor">
|
||||
<div class="config-editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">Файлы XLX</p>
|
||||
<h2>Редактор blacklist, whitelist, interlink и terminal</h2>
|
||||
<p class="muted-text">Изменения сохраняются прямо в `/xlxd`. После правки перезапусти `xlxd`, если файл требует перечитывания сервисом.</p>
|
||||
</div>
|
||||
<button class="button ghost" id="loadConfigFiles" type="button">Загрузить файлы</button>
|
||||
</div>
|
||||
|
||||
<div class="file-tabs" id="fileTabs"></div>
|
||||
|
||||
<div class="file-meta" id="fileMeta">
|
||||
<span>Файл не выбран</span>
|
||||
</div>
|
||||
|
||||
<label class="editor-label">Содержимое файла
|
||||
<textarea id="fileEditor" spellcheck="false" placeholder="Загрузите файл для редактирования"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="button" id="saveConfigFile" type="button">Сохранить файл</button>
|
||||
<button class="button ghost" id="reloadConfigFile" type="button">Отменить изменения</button>
|
||||
</div>
|
||||
<pre class="result" id="fileResult"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -52,12 +52,28 @@ install -m 0755 "$REPO_ROOT/scripts/xlx-control" /usr/local/sbin/xlx-control
|
||||
install -m 0644 "$REPO_ROOT/deploy/systemd/xlxd.service" "/etc/systemd/system/${XLX_SERVICE_NAME}.service"
|
||||
|
||||
/usr/local/sbin/xlx-control render-modules
|
||||
|
||||
if getent group www-data >/dev/null 2>&1; then
|
||||
chgrp www-data "$XLX_INSTALL_PATH" || true
|
||||
chmod 775 "$XLX_INSTALL_PATH" || true
|
||||
for file in xlxd.blacklist xlxd.whitelist xlxd.interlink xlxd.terminal; do
|
||||
if [[ -f "$XLX_INSTALL_PATH/$file" ]]; then
|
||||
chgrp www-data "$XLX_INSTALL_PATH/$file" || true
|
||||
chmod 664 "$XLX_INSTALL_PATH/$file" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${XLX_SERVICE_NAME}.service"
|
||||
|
||||
cat <<EOF
|
||||
XLX reflector installed.
|
||||
|
||||
This script installs only the xlxd reflector.
|
||||
For the full system with web panel, admin dashboard, YooKassa and config editor, run:
|
||||
sudo bash scripts/install-system.sh
|
||||
|
||||
Next commands:
|
||||
sudo systemctl start ${XLX_SERVICE_NAME}
|
||||
sudo systemctl status ${XLX_SERVICE_NAME}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class XlxdConfigFileService
|
||||
{
|
||||
private const FILES = [
|
||||
'blacklist' => [
|
||||
'name' => 'Blacklist',
|
||||
'filename' => 'xlxd.blacklist',
|
||||
'description' => 'Позывные и узлы, которым запрещен доступ.',
|
||||
],
|
||||
'whitelist' => [
|
||||
'name' => 'Whitelist',
|
||||
'filename' => 'xlxd.whitelist',
|
||||
'description' => 'Позывные и узлы, которым разрешен доступ.',
|
||||
],
|
||||
'interlink' => [
|
||||
'name' => 'Interlink',
|
||||
'filename' => 'xlxd.interlink',
|
||||
'description' => 'Связи с другими XLX reflectors.',
|
||||
],
|
||||
'terminal' => [
|
||||
'name' => 'Terminal',
|
||||
'filename' => 'xlxd.terminal',
|
||||
'description' => 'Разрешенные терминалы и узлы.',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$files = [];
|
||||
foreach (array_keys(self::FILES) as $key) {
|
||||
$files[] = $this->read($key);
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function read(string $key): array
|
||||
{
|
||||
$meta = $this->meta($key);
|
||||
$path = $this->path($key);
|
||||
$exists = is_file($path);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'name' => $meta['name'],
|
||||
'filename' => $meta['filename'],
|
||||
'path' => $path,
|
||||
'description' => $meta['description'],
|
||||
'exists' => $exists,
|
||||
'writable' => $exists ? is_writable($path) : is_writable(dirname($path)),
|
||||
'size' => $exists ? filesize($path) : 0,
|
||||
'updated_at' => $exists ? date('Y-m-d H:i:s', filemtime($path)) : null,
|
||||
'content' => $exists ? (string) file_get_contents($path) : '',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(string $key, string $content): array
|
||||
{
|
||||
$path = $this->path($key);
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
throw new RuntimeException('XLX directory does not exist: ' . $directory);
|
||||
}
|
||||
|
||||
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
|
||||
if ($normalized !== '' && !str_ends_with($normalized, "\n")) {
|
||||
$normalized .= "\n";
|
||||
}
|
||||
|
||||
if (file_put_contents($path, $normalized, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Cannot write XLX config file: ' . $path);
|
||||
}
|
||||
|
||||
return $this->read($key);
|
||||
}
|
||||
|
||||
private function meta(string $key): array
|
||||
{
|
||||
if (!array_key_exists($key, self::FILES)) {
|
||||
throw new RuntimeException('Unknown XLX config file.');
|
||||
}
|
||||
|
||||
return self::FILES[$key];
|
||||
}
|
||||
|
||||
private function path(string $key): string
|
||||
{
|
||||
$meta = $this->meta($key);
|
||||
$installPath = rtrim((string) $this->config->get('xlx.install_path', '/xlxd'), '/');
|
||||
$path = $installPath . '/' . $meta['filename'];
|
||||
|
||||
$realDirectory = realpath($installPath);
|
||||
if ($realDirectory !== false) {
|
||||
$candidate = $realDirectory . DIRECTORY_SEPARATOR . $meta['filename'];
|
||||
if (!str_starts_with($candidate, $realDirectory . DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('Invalid XLX config path.');
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user