From c2f62feb09905722a16768aff783661788cc3bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= Date: Mon, 18 May 2026 06:50:36 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20XLX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/ADMIN_DASHBOARD.md | 19 +++++ public/assets/admin.js | 108 +++++++++++++++++++++++++ public/assets/styles.css | 86 +++++++++++++++++++- public/index.php | 13 +++ public/views/admin.php | 27 +++++++ scripts/install-xlxd.sh | 16 ++++ src/Domain/XlxdConfigFileService.php | 115 +++++++++++++++++++++++++++ 8 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/Domain/XlxdConfigFileService.php diff --git a/README.md b/README.md index ca3a9b3..f1e4aaa 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - установка и управление `xlxd`; - главный пользовательский дашборд `/`; - админка настроек `xlxd` `/admin`; +- графический редактор `/xlxd/xlxd.blacklist`, `xlxd.whitelist`, `xlxd.interlink`, `xlxd.terminal`; - экспорт активных пользователей для будущего access gateway. ## Компоненты diff --git a/docs/ADMIN_DASHBOARD.md b/docs/ADMIN_DASHBOARD.md index b47fb70..e0e1891 100644 --- a/docs/ADMIN_DASHBOARD.md +++ b/docs/ADMIN_DASHBOARD.md @@ -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` права записи на эти файлы, чтобы сохранение работало из веб-интерфейса. diff --git a/public/assets/admin.js b/public/assets/admin.js index 00067ba..fcb6c57 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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 = 'Файл не выбран'; + return; + } + + const writable = file.writable ? 'доступен для записи' : 'нет прав на запись'; + const exists = file.exists ? 'существует' : 'будет создан при сохранении'; + fileMeta.innerHTML = ` + ${file.path} + ${exists} + ${writable} + ${file.updated_at || 'без даты'} + `; +} + +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 = { diff --git a/public/assets/styles.css b/public/assets/styles.css index 1567038..8f47238 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -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; } diff --git a/public/index.php b/public/index.php index bd427b4..dc13ae5 100644 --- a/public/index.php +++ b/public/index.php @@ -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); diff --git a/public/views/admin.php b/public/views/admin.php index bd4fac8..13c69b4 100644 --- a/public/views/admin.php +++ b/public/views/admin.php @@ -81,6 +81,33 @@

         
     
+
+    
+
+
+

Файлы XLX

+

Редактор blacklist, whitelist, interlink и terminal

+

Изменения сохраняются прямо в `/xlxd`. После правки перезапусти `xlxd`, если файл требует перечитывания сервисом.

+
+ +
+ +
+ +
+ Файл не выбран +
+ + + +
+ + +
+

+    
diff --git a/scripts/install-xlxd.sh b/scripts/install-xlxd.sh index 2c7e841..a0055af 100644 --- a/scripts/install-xlxd.sh +++ b/scripts/install-xlxd.sh @@ -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 < [ + '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; + } +}