From 7808cc415807a21c9b2a28348ae762fc3eca38f9 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:38:31 +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=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8?= =?UTF-8?q?=D0=BA=20XLX=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B5=D0=B1-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 181 +++++++++++++----- VERSION | 2 +- bootstrap.php | 26 +++ config/config.example.php | 73 +++++--- config/xlxd.env.example | 15 ++ database/schema.sql | 30 ++- deploy/apache/xlx-panel.conf | 14 ++ deploy/systemd/xlxd.service | 17 ++ docs/ADMIN_DASHBOARD.md | 33 ++++ docs/ARCHITECTURE.md | 6 +- docs/INSTALL_SYSTEM.md | 87 +++++++++ docs/INSTALL_XLXD.md | 71 +++++++ docs/PAYMENTS_AND_REGISTRATION.md | 123 +++++++++++++ docs/ROADMAP.md | 2 +- public/assets/admin.js | 90 +++++++++ public/assets/app.js | 35 ++++ public/assets/styles.css | 244 +++++++++++++++++++++++++ public/index.php | 123 +++++++++++++ public/views/admin.php | 87 +++++++++ public/views/home.php | 78 ++++++++ scripts/firewall-ufw.sh | 15 ++ scripts/install-system.sh | 170 +++++++++++++++++ scripts/install-xlxd.sh | 67 +++++++ scripts/xlx-control | 69 +++++++ src/Domain/AccessCredentialService.php | 44 +++++ src/Domain/AccessExportService.php | 33 ++++ src/Domain/IdAllocator.php | 47 +++++ src/Domain/PaymentService.php | 244 +++++++++++++++++++++++++ src/Domain/RegistrationService.php | 122 +++++++++++++ src/Domain/XlxdSettingsService.php | 153 ++++++++++++++++ src/Domain/YooKassaClient.php | 91 +++++++++ src/Support/Config.php | 25 +++ src/Support/Database.php | 32 ++++ src/Support/Input.php | 23 +++ src/Support/Response.php | 21 +++ src/Support/Security.php | 46 +++++ 36 files changed, 2461 insertions(+), 78 deletions(-) create mode 100644 bootstrap.php create mode 100644 config/xlxd.env.example create mode 100644 deploy/apache/xlx-panel.conf create mode 100644 deploy/systemd/xlxd.service create mode 100644 docs/ADMIN_DASHBOARD.md create mode 100644 docs/INSTALL_SYSTEM.md create mode 100644 docs/INSTALL_XLXD.md create mode 100644 docs/PAYMENTS_AND_REGISTRATION.md create mode 100644 public/assets/admin.js create mode 100644 public/assets/app.js create mode 100644 public/assets/styles.css create mode 100644 public/index.php create mode 100644 public/views/admin.php create mode 100644 public/views/home.php create mode 100644 scripts/firewall-ufw.sh create mode 100644 scripts/install-system.sh create mode 100644 scripts/install-xlxd.sh create mode 100644 scripts/xlx-control create mode 100644 src/Domain/AccessCredentialService.php create mode 100644 src/Domain/AccessExportService.php create mode 100644 src/Domain/IdAllocator.php create mode 100644 src/Domain/PaymentService.php create mode 100644 src/Domain/RegistrationService.php create mode 100644 src/Domain/XlxdSettingsService.php create mode 100644 src/Domain/YooKassaClient.php create mode 100644 src/Support/Config.php create mode 100644 src/Support/Database.php create mode 100644 src/Support/Input.php create mode 100644 src/Support/Response.php create mode 100644 src/Support/Security.php diff --git a/README.md b/README.md index de7108c..ca3a9b3 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,145 @@ # XLX Server -Собственный XLX/DMR-сервер для пользователей Pi-Star с платной регистрацией, автоматической выдачей ID, проверкой позывного по внешней базе и персональным доступом по логину/паролю. +Собственный XLX/DMR-сервер для пользователей Pi-Star. -## Цель проекта +## Что уже есть -Сделать управляемую платформу для DMR-пользователей: +- платная регистрация; +- проверка позывного по внутренней базе зарегистрированных пользователей; +- YooKassa; +- ручной перевод с подтверждением по чеку; +- автоматическая выдача внутреннего DMR ID; +- отдельный DMR-пароль для Pi-Star; +- установка и управление `xlxd`; +- главный пользовательский дашборд `/`; +- админка настроек `xlxd` `/admin`; +- экспорт активных пользователей для будущего access gateway. -- регистрация пользователя по позывному; -- автоматическая проверка позывного; -- платная активация аккаунта; -- автоматическая выдача внутреннего XLX/DMR ID; -- генерация данных подключения для Pi-Star; -- управление доступом к XLX-серверу; -- админ-панель пользователей, оплат, ID, логов и подключений; -- автообновление сервера и веб-панели. +## Компоненты -## Базовая архитектура +- `xlxd` - мультипротокольный XLX reflector. +- `public/index.php` - PHP API и web routes. +- `public/assets` - стили и JS дашбордов. +- `src/Domain` - регистрация, проверка дублей позывного, оплата, выдача ID, настройки XLX. +- `database/schema.sql` - схема MariaDB/MySQL. +- `scripts/install-xlxd.sh` - установка и сборка `xlxd`. +- `scripts/xlx-control` - start/stop/status для systemd. -Проект делится на несколько частей: +## Быстрый старт XLX -1. **XLX Core** — установленный и управляемый XLX reflector. -2. **Access Gateway** — слой контроля доступа пользователей, паролей и разрешённых подключений. -3. **Web Panel** — личный кабинет и админка. -4. **Billing** — платная регистрация и продление доступа. -5. **Callsign Verifier** — проверка позывных по базе радиолюбителей. -6. **ID Allocator** — автоматическая выдача уникального ID. -7. **Installer/Updater** — установка, обновления, бэкапы и восстановление. +Для полной установки панели, базы, YooKassa-заготовки и самого `xlxd` используй общий установщик. + +### Установка без домена + +```bash +sudo apt update +sudo apt install -y git +git clone https://github.com/viktor138irk/xlx.git +cd xlx +sudo bash scripts/install-system.sh +``` + +Установщик сам выберет адрес сервера: + +1. `XLX_PUBLIC_HOST`, если переменная задана; +2. `hostname -f`; +3. первый IP из `hostname -I`; +4. `127.0.0.1`, если ничего не найдено. + +В конце установки будут показаны: + +- адрес сайта; +- адрес админки; +- admin token; +- webhook URL для YooKassa; +- логин и пароль БД. + +### Установка с доменом + +```bash +sudo apt update +sudo apt install -y git +git clone https://github.com/viktor138irk/xlx.git +cd xlx +sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh +``` + +Если домен появится позже, его можно поменять в админке `/admin`. + +### Установка с YooKassa + +```bash +sudo \ + XLX_PUBLIC_HOST=xlx.example.ru \ + YOOKASSA_SHOP_ID=123456 \ + YOOKASSA_SECRET_KEY=secret_key \ + bash scripts/install-system.sh +``` + +Webhook для YooKassa установщик выведет в конце. Он выглядит так: + +```text +http://адрес-сервера/api/webhooks/yookassa?token=секрет +``` + +Если сервер работает через HTTPS: + +```bash +sudo \ + XLX_PUBLIC_SCHEME=https \ + XLX_PUBLIC_HOST=xlx.example.ru \ + YOOKASSA_SHOP_ID=123456 \ + YOOKASSA_SECRET_KEY=secret_key \ + bash scripts/install-system.sh +``` + +### После установки + +Открой: + +- `/` - главная страница и регистрация пользователей; +- `/admin` - админка настроек `xlxd`; +- `/api/health` - проверка API. + +Для Pi-Star нужен UDP порт `62030`. Также обычно открывают: + +```bash +sudo bash scripts/firewall-ufw.sh +``` + +### Управление XLX + +```bash +sudo systemctl start xlxd +sudo systemctl stop xlxd +sudo systemctl restart xlxd +sudo systemctl status xlxd +sudo tail -f /var/log/xlxd.log +``` + +### Только установка reflector + +Если нужна только сборка и запуск `xlxd` без полной панели: + +```bash +git clone https://github.com/viktor138irk/xlx.git +cd xlx +sudo mkdir -p /etc/xlx +sudo cp config/xlxd.env.example /etc/xlx/xlxd.env +sudo nano /etc/xlx/xlxd.env +sudo bash scripts/install-xlxd.sh +sudo systemctl start xlxd +``` + +## Документация + +- `docs/INSTALL_SYSTEM.md` - установка всей системы без привязки к домену или IP. +- `docs/INSTALL_XLXD.md` - установка reflector. +- `docs/PAYMENTS_AND_REGISTRATION.md` - регистрация, YooKassa и чеки. +- `docs/ADMIN_DASHBOARD.md` - главная и админка. ## Статус -v0.1.0 — проектирование и стартовый каркас. +v0.2.0-dev. -## Предполагаемый стек - -- Ubuntu 22.04/24.04 LTS; -- XLX reflector; -- PHP 8.3 или Python/FastAPI для панели/API; -- MariaDB/MySQL; -- Nginx; -- systemd services; -- YooKassa/ЮMoney/ручное подтверждение оплаты на первом этапе; -- автоустановщик shell + миграции БД. - -## Pi-Star сценарий - -Пользователь после оплаты получает: - -- адрес сервера; -- порт; -- логин/позывной; -- пароль; -- назначенный ID; -- инструкцию подключения Pi-Star. - -## Версионирование - -- малые фиксы: `+0.0.1`; -- заметные функции: `+0.1.0`; -- архитектурные изменения: `+1.0.0`. +Следующий шаг - UDP access gateway для жесткой проверки оплаченного доступа перед `xlxd`. diff --git a/VERSION b/VERSION index 6e8bf73..70426f8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0-dev diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..5fd4350 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,26 @@ + [ 'name' => 'XLX Server', - 'env' => 'production', - 'debug' => false, - 'base_url' => 'https://xlx.example.com', - 'timezone' => 'Europe/Amsterdam', + 'env' => getenv('XLX_APP_ENV') ?: 'production', + 'debug' => (bool) (getenv('XLX_DEBUG') ?: false), + 'base_url' => $baseUrl, + 'timezone' => getenv('XLX_TIMEZONE') ?: 'Europe/Moscow', ], 'database' => [ - 'host' => '127.0.0.1', - 'port' => 3306, - 'name' => 'xlx_server', - 'user' => 'xlx_user', - 'password' => 'change_me', + 'host' => getenv('XLX_DB_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('XLX_DB_PORT') ?: 3306), + 'name' => getenv('XLX_DB_NAME') ?: 'xlx_server', + 'user' => getenv('XLX_DB_USER') ?: 'xlx_user', + 'password' => getenv('XLX_DB_PASSWORD') ?: 'change_me', 'charset' => 'utf8mb4', ], 'xlx' => [ - 'reflector_name' => 'XLX000', - 'server_host' => 'xlx.example.com', - 'dashboard_port' => 8080, - 'dmr_port' => 62030, - 'default_module' => 'A', - 'config_path' => '/etc/xlx', - 'service_name' => 'xlxd', + 'reflector_name' => getenv('XLX_REFLECTOR_NAME') ?: 'XLX000', + 'server_host' => getenv('XLX_PUBLIC_HOST') ?: $host, + 'sysop_callsign' => getenv('XLX_SYSOP_CALLSIGN') ?: 'N0CALL', + 'sysop_email' => getenv('XLX_SYSOP_EMAIL') ?: 'admin@' . $hostWithoutPort, + 'country' => getenv('XLX_COUNTRY') ?: 'RU', + 'dashboard_port' => (int) (getenv('XLX_DASHBOARD_PORT') ?: 8080), + 'dmr_port' => (int) (getenv('XLX_DMR_PORT') ?: 62030), + 'ysf_port' => (int) (getenv('XLX_YSF_PORT') ?: 42000), + 'default_module' => getenv('XLX_DEFAULT_MODULE') ?: 'A', + 'modules' => getenv('XLX_MODULES') ?: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'config_path' => getenv('XLX_CONFIG_PATH') ?: '/etc/xlx', + 'install_path' => getenv('XLX_INSTALL_PATH') ?: '/xlxd', + 'source_path' => getenv('XLX_SOURCE_PATH') ?: '/usr/src/xlxd', + 'log_path' => getenv('XLX_LOG_PATH') ?: '/var/log/xlxd.log', + 'service_name' => getenv('XLX_SERVICE_NAME') ?: 'xlxd', ], 'id_allocator' => [ - 'min_id' => 9000001, - 'max_id' => 9099999, + 'min_id' => (int) (getenv('XLX_MIN_ID') ?: 9000001), + 'max_id' => (int) (getenv('XLX_MAX_ID') ?: 9099999), ], 'billing' => [ - 'mode' => 'manual', - 'currency' => 'RUB', + 'mode' => getenv('XLX_BILLING_MODE') ?: 'yookassa', + 'currency' => getenv('XLX_CURRENCY') ?: 'RUB', + 'default_duration_days' => (int) (getenv('XLX_DEFAULT_DURATION_DAYS') ?: 30), + 'manual_transfer_enabled' => (bool) (getenv('XLX_MANUAL_TRANSFER_ENABLED') ?: true), + 'manual_transfer_instructions' => getenv('XLX_MANUAL_TRANSFER_INSTRUCTIONS') ?: 'Переведите сумму тарифа и отправьте номер или ссылку чека на проверку.', + 'yookassa' => [ + 'shop_id' => getenv('YOOKASSA_SHOP_ID') ?: 'change_me', + 'secret_key' => getenv('YOOKASSA_SECRET_KEY') ?: 'change_me', + 'return_url' => getenv('YOOKASSA_RETURN_URL') ?: $baseUrl . '/payment/return', + 'webhook_secret' => getenv('YOOKASSA_WEBHOOK_SECRET') ?: 'change_this_webhook_token', + 'capture' => (bool) (getenv('YOOKASSA_CAPTURE') ?: true), + ], ], 'security' => [ - 'session_name' => 'xlx_session', - 'password_min_length' => 10, - 'dmr_password_length' => 16, + 'session_name' => getenv('XLX_SESSION_NAME') ?: 'xlx_session', + 'password_min_length' => (int) (getenv('XLX_PASSWORD_MIN_LENGTH') ?: 10), + 'dmr_password_length' => (int) (getenv('XLX_DMR_PASSWORD_LENGTH') ?: 16), + ], + + 'admin' => [ + 'api_token' => getenv('XLX_ADMIN_TOKEN') ?: 'change_this_long_random_admin_token', ], ]; diff --git a/config/xlxd.env.example b/config/xlxd.env.example new file mode 100644 index 0000000..657f397 --- /dev/null +++ b/config/xlxd.env.example @@ -0,0 +1,15 @@ +XLX_REFLECTOR_NAME=XLX000 +XLX_SERVER_HOST=xlx.example.com +XLX_SYSOP_CALLSIGN=N0CALL +XLX_SYSOP_EMAIL=admin@example.com +XLX_COUNTRY=RU +XLX_DASHBOARD_PORT=8080 +XLX_DMR_PORT=62030 +XLX_YSF_PORT=42000 +XLX_DEFAULT_MODULE=A +XLX_MODULES=ABCDEFGHIJKLMNOPQRSTUVWXYZ +XLX_INSTALL_PATH=/xlxd +XLX_SOURCE_PATH=/usr/src/xlxd +XLX_LOG_PATH=/var/log/xlxd.log +XLX_SERVICE_NAME=xlxd +XLX_REPO_URL=https://github.com/LX3JL/xlxd.git diff --git a/database/schema.sql b/database/schema.sql index 846543c..8d2c6af 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS dmr_ids ( CREATE TABLE IF NOT EXISTS tariffs ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(120) NOT NULL, + name VARCHAR(120) NOT NULL UNIQUE, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT 'RUB', duration_days INT UNSIGNED NOT NULL DEFAULT 30, @@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS payments ( currency CHAR(3) NOT NULL DEFAULT 'RUB', provider VARCHAR(64) NOT NULL DEFAULT 'manual', provider_payment_id VARCHAR(190) NULL, + confirmation_url TEXT NULL, status ENUM('pending', 'paid', 'failed', 'cancelled', 'refunded') NOT NULL DEFAULT 'pending', paid_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -56,6 +57,21 @@ CREATE TABLE IF NOT EXISTS payments ( CONSTRAINT fk_payments_tariff FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS payment_receipts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + payment_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + receipt_reference VARCHAR(255) NOT NULL, + comment TEXT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending', + reviewed_by_user_id BIGINT UNSIGNED NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_payment_receipts_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE, + CONSTRAINT fk_payment_receipts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_payment_receipts_reviewer FOREIGN KEY (reviewed_by_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS access_credentials ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL UNIQUE, @@ -101,6 +117,16 @@ CREATE TABLE IF NOT EXISTS audit_logs ( CONSTRAINT fk_audit_logs_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS xlx_service_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + status VARCHAR(64) NOT NULL, + message TEXT NULL, + payload_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_xlx_service_events_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + INSERT INTO tariffs (name, price, currency, duration_days) VALUES ('Стартовый доступ 30 дней', 300.00, 'RUB', 30) -ON DUPLICATE KEY UPDATE name = name; +ON DUPLICATE KEY UPDATE price = VALUES(price), currency = VALUES(currency), duration_days = VALUES(duration_days); diff --git a/deploy/apache/xlx-panel.conf b/deploy/apache/xlx-panel.conf new file mode 100644 index 0000000..d6e007c --- /dev/null +++ b/deploy/apache/xlx-panel.conf @@ -0,0 +1,14 @@ + + ServerName _ + DocumentRoot __PUBLIC_DIR__ + + + Options FollowSymLinks + AllowOverride None + Require all granted + FallbackResource /index.php + + + ErrorLog ${APACHE_LOG_DIR}/xlx-panel-error.log + CustomLog ${APACHE_LOG_DIR}/xlx-panel-access.log combined + diff --git a/deploy/systemd/xlxd.service b/deploy/systemd/xlxd.service new file mode 100644 index 0000000..d713aac --- /dev/null +++ b/deploy/systemd/xlxd.service @@ -0,0 +1,17 @@ +[Unit] +Description=XLX Multiprotocol Reflector +After=network-online.target +Wants=network-online.target + +[Service] +Type=forking +EnvironmentFile=/etc/xlx/xlxd.env +ExecStart=/usr/local/sbin/xlx-control start +ExecStop=/usr/local/sbin/xlx-control stop +ExecReload=/usr/local/sbin/xlx-control restart +PIDFile=/var/run/xlxd.pid +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/docs/ADMIN_DASHBOARD.md b/docs/ADMIN_DASHBOARD.md new file mode 100644 index 0000000..b47fb70 --- /dev/null +++ b/docs/ADMIN_DASHBOARD.md @@ -0,0 +1,33 @@ +# Админка и главный дашборд + +## Главная + +Главная страница доступна по адресу `/`. + +На ней есть: + +- краткое описание сервиса; +- карточки статуса DMR/YooKassa/Pi-Star; +- форма регистрации пользователя; +- вывод ссылки YooKassa и инструкции для перевода по чеку. + +## Админка + +Админка доступна по адресу `/admin`. + +Чтобы загрузить или сохранить настройки, нужно ввести `X-Admin-Token` из `config/config.php`. + +Форма меняет: + +- имя reflector, например `XLX138`; +- домен сервера; +- sysop callsign и email; +- страну; +- DMR/YSF/dashboard порты; +- модуль по умолчанию; +- список активных модулей; +- пути установки, исходников и логов; +- имя systemd service; +- URL репозитория `xlxd`. + +При сохранении с флагом `apply` API пытается обновить `/etc/xlx/xlxd.env`. Если PHP работает без прав записи в `/etc/xlx`, настройки все равно сохранятся в БД, а в админке будет виден готовый env-preview. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17fdf22..7d7a530 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,18 +56,18 @@ XLX Server — это управляемая платформа для DMR-по ### 2.3 Callsign Verifier -Модуль проверки позывного по внешним источникам. +Модуль проверки позывного по внутренней базе уже зарегистрированных пользователей. MVP-логика: - пользователь вводит позывной; - система нормализует его в верхний регистр; - проверяет формат; -- делает запрос к настроенной базе; +- проверяет, что позывной еще не занят в таблице пользователей; - сохраняет результат проверки; - при ошибке отправляет заявку в ручную модерацию. -Важно: внешний источник проверки должен быть заменяемым адаптером, чтобы не прибивать проект гвоздями к одной базе. +Важно: уникальность дополнительно защищена индексом `users.callsign`, поэтому дубль не пройдет даже при одновременной регистрации. ### 2.4 ID Allocator diff --git a/docs/INSTALL_SYSTEM.md b/docs/INSTALL_SYSTEM.md new file mode 100644 index 0000000..4696774 --- /dev/null +++ b/docs/INSTALL_SYSTEM.md @@ -0,0 +1,87 @@ +# Установка всей системы + +`scripts/install-system.sh` ставит сразу: + +- Apache + PHP; +- MariaDB; +- базу и пользователя БД; +- web-панель `/`; +- админку `/admin`; +- `xlxd`; +- systemd service для reflector; +- `/etc/xlx/xlxd.env`. + +## Без домена + +На чистом Ubuntu/Debian сервере: + +```bash +git clone https://github.com/viktor138irk/xlx.git +cd xlx +sudo bash scripts/install-system.sh +``` + +Установщик сам выберет адрес: + +1. `XLX_PUBLIC_HOST`, если переменная задана; +2. `hostname -f`, если он похож на реальное имя; +3. первый IP из `hostname -I`; +4. `127.0.0.1` как последний fallback. + +В конце он покажет: + +- URL сайта; +- URL админки; +- admin token; +- webhook URL для YooKassa; +- логин и пароль БД. + +## С доменом + +Если домен уже есть: + +```bash +sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh +``` + +Если домен появится позже, открой `/admin` и поменяй поле `Домен`. Это обновит настройки `xlxd` без ручного редактирования конфигов. + +## YooKassa + +Можно передать ключи сразу: + +```bash +sudo \ + XLX_PUBLIC_HOST=xlx.example.ru \ + YOOKASSA_SHOP_ID=123456 \ + YOOKASSA_SECRET_KEY=secret \ + bash scripts/install-system.sh +``` + +Webhook будет такого вида: + +```text +http://адрес-сервера/api/webhooks/yookassa?token=секрет +``` + +Если включишь HTTPS, укажи: + +```bash +sudo XLX_PUBLIC_SCHEME=https XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh +``` + +## Повторный запуск + +Скрипт можно запускать повторно. Он: + +- обновит `config/config.php`; +- обновит `/etc/xlx/xlxd.env`; +- применит SQL-схему; +- пересоберет `xlxd`; +- перезагрузит Apache. + +Перед повторным запуском с существующей БД лучше передать старый пароль: + +```bash +sudo XLX_DB_PASSWORD='старый-пароль' bash scripts/install-system.sh +``` diff --git a/docs/INSTALL_XLXD.md b/docs/INSTALL_XLXD.md new file mode 100644 index 0000000..28dd79f --- /dev/null +++ b/docs/INSTALL_XLXD.md @@ -0,0 +1,71 @@ +# Установка XLX reflector + +Проект состоит из двух частей: + +- `xlxd` - сам мультипротокольный XLX reflector; +- PHP-панель - регистрация, YooKassa, ручной чек, выдача DMR ID и данных для Pi-Star. + +## Требования + +- Ubuntu 22.04/24.04 или Debian 12. +- Публичный статический IP. +- DNS-имя, например `xlx.example.com`. +- Открытый UDP `62030` для Pi-Star MMDVM. +- Root-доступ на сервер. + +## Настройка XLX + +```bash +git clone https://github.com/viktor138irk/xlx.git +cd xlx +sudo mkdir -p /etc/xlx +sudo cp config/xlxd.env.example /etc/xlx/xlxd.env +sudo nano /etc/xlx/xlxd.env +``` + +Минимально поменяй: + +```bash +XLX_REFLECTOR_NAME=XLX138 +XLX_SERVER_HOST=xlx.example.com +XLX_SYSOP_CALLSIGN=R0XXX +XLX_SYSOP_EMAIL=admin@example.com +XLX_COUNTRY=RU +``` + +## Установка сервера + +```bash +sudo bash scripts/install-xlxd.sh +sudo ./scripts/firewall-ufw.sh +sudo systemctl start xlxd +sudo systemctl status xlxd +``` + +Логи: + +```bash +sudo tail -f /var/log/xlxd.log +``` + +## Управление + +```bash +sudo systemctl start xlxd +sudo systemctl stop xlxd +sudo systemctl restart xlxd +sudo systemctl status xlxd +``` + +## Pi-Star + +После оплаты панель выдаст: + +- сервер: `XLX_SERVER_HOST`; +- порт: `62030`; +- логин: позывной; +- пароль: отдельный DMR-пароль; +- DMR ID: выданный системой ID; +- модуль: `A` по умолчанию. + +Полный DMR-пароль показывается один раз. Если пользователь потерял пароль, нужно выпустить новый. diff --git a/docs/PAYMENTS_AND_REGISTRATION.md b/docs/PAYMENTS_AND_REGISTRATION.md new file mode 100644 index 0000000..5025649 --- /dev/null +++ b/docs/PAYMENTS_AND_REGISTRATION.md @@ -0,0 +1,123 @@ +# Регистрация и оплата + +## Сценарий пользователя + +1. Пользователь отправляет позывной, email и пароль кабинета. +2. Система нормализует позывной и проверяет, что такого позывного еще нет во внутренней базе. +3. Если позывной принят, создается платеж. +4. Пользователь выбирает: + - оплату через YooKassa; + - перевод с отправкой чека на ручную проверку. +5. После подтверждения оплаты система: + - активирует аккаунт; + - назначает внутренний DMR ID; + - генерирует отдельный DMR-пароль; + - отдает данные подключения Pi-Star. + +## YooKassa + +В `config/config.php`: + +```php +'billing' => [ + 'mode' => 'yookassa', + 'yookassa' => [ + 'shop_id' => '...', + 'secret_key' => '...', + 'return_url' => 'https://xlx.example.com/payment/return', + 'webhook_secret' => 'long-random-token', + 'capture' => true, + ], +], +``` + +Webhook URL: + +```text +https://xlx.example.com/api/webhooks/yookassa?token=long-random-token +``` + +Обрабатывается событие: + +```text +payment.succeeded +``` + +Повторный webhook безопасен: если платеж уже оплачен, система вернет текущий доступ без повторной выдачи ID. + +## Перевод с чеком + +Если пользователь оплатил переводом, он отправляет номер или ссылку чека: + +```http +POST /api/payments/receipt +Content-Type: application/json + +{ + "payment_id": 1, + "user_id": 1, + "receipt_reference": "https://bank.example/receipt/123", + "comment": "Оплата за R0XXX" +} +``` + +Админ подтверждает чек: + +```http +POST /api/admin/receipts/approve +X-Admin-Token: change_this_long_random_admin_token +Content-Type: application/json + +{ + "receipt_id": 1, + "admin_user_id": 1 +} +``` + +## Основные API + +Регистрация: + +```http +POST /api/register +Content-Type: application/json + +{ + "callsign": "R0XXX", + "email": "user@example.com", + "password": "strong-password" +} +``` + +Подтверждение платежа админом: + +```http +POST /api/admin/payments/confirm +X-Admin-Token: change_this_long_random_admin_token +Content-Type: application/json + +{ + "payment_id": 1, + "admin_user_id": 1 +} +``` + +Экспорт активных пользователей для gateway: + +```http +GET /api/admin/access/export +X-Admin-Token: change_this_long_random_admin_token +``` + +## Pi-Star и пароль + +`xlxd` поднимает сам reflector и принимает MMDVM/DMR на UDP `62030`. Персональная проверка логина/пароля на уровне Pi-Star требует отдельного access gateway/proxy или auth-слоя перед reflector. + +В проекте уже есть: + +- хранение отдельного DMR-пароля; +- включение/выключение доступа; +- экспорт активных пользователей; +- единая активация после YooKassa или ручного чека. + +Следующий слой - UDP gateway, который будет читать экспорт активных пользователей и пропускать к `xlxd` только оплаченные подключения. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82bb7af..8ce854e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ - [ ] форма регистрации; - [ ] нормализация позывного; - [ ] проверка формата позывного; -- [ ] адаптер внешней базы позывных; +- [x] проверка дублей позывных по внутренней базе; - [ ] ручная модерация спорных заявок; - [ ] автоматическая выдача ID; - [ ] защита от дублей; diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..00067ba --- /dev/null +++ b/public/assets/admin.js @@ -0,0 +1,90 @@ +const tokenInput = document.querySelector('#adminToken'); +const form = document.querySelector('#settingsForm'); +const envPreview = document.querySelector('#envPreview'); +const adminResult = document.querySelector('#adminResult'); +const loadButton = document.querySelector('#loadSettings'); +const saveButton = document.querySelector('#saveSettings'); +const applyButton = document.querySelector('#applySettings'); + +function token() { + return tokenInput.value.trim(); +} + +function setResult(text) { + adminResult.textContent = text; +} + +function formData() { + return Object.fromEntries(new FormData(form).entries()); +} + +function fillForm(settings) { + for (const [key, value] of Object.entries(settings)) { + const input = form.elements.namedItem(key); + if (input) { + input.value = value ?? ''; + } + } +} + +async function loadSettings() { + setResult('Загружаем...'); + const response = await fetch('/api/admin/xlxd/settings', { + headers: { 'X-Admin-Token': token() }, + }); + const payload = await response.json(); + if (!payload.ok) { + setResult(payload.error || 'Ошибка загрузки'); + return; + } + + fillForm(payload.data.settings); + envPreview.textContent = payload.data.env; + setResult('Настройки загружены.'); +} + +async function saveSettings(apply) { + setResult(apply ? 'Сохраняем и применяем...' : 'Сохраняем...'); + const response = await fetch('/api/admin/xlxd/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Token': token(), + }, + body: JSON.stringify({ settings: formData(), apply }), + }); + const payload = await response.json(); + if (!payload.ok) { + setResult(payload.error || 'Ошибка сохранения'); + return; + } + + envPreview.textContent = payload.data.env; + setResult(payload.data.message); +} + +loadButton?.addEventListener('click', loadSettings); +saveButton?.addEventListener('click', () => saveSettings(false)); +applyButton?.addEventListener('click', () => saveSettings(true)); + +form?.addEventListener('input', () => { + const data = formData(); + const lines = { + XLX_REFLECTOR_NAME: data.reflector_name, + XLX_SERVER_HOST: data.server_host, + XLX_SYSOP_CALLSIGN: data.sysop_callsign, + XLX_SYSOP_EMAIL: data.sysop_email, + XLX_COUNTRY: data.country, + XLX_DASHBOARD_PORT: data.dashboard_port, + XLX_DMR_PORT: data.dmr_port, + XLX_YSF_PORT: data.ysf_port, + XLX_DEFAULT_MODULE: data.default_module, + XLX_MODULES: data.modules, + XLX_INSTALL_PATH: data.install_path, + XLX_SOURCE_PATH: data.source_path, + XLX_LOG_PATH: data.log_path, + XLX_SERVICE_NAME: data.service_name, + XLX_REPO_URL: data.repo_url, + }; + envPreview.textContent = Object.entries(lines).map(([key, value]) => `${key}=${value || ''}`).join('\n'); +}); diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..6f0ca10 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,35 @@ +const form = document.querySelector('#registerForm'); +const result = document.querySelector('#registerResult'); + +form?.addEventListener('submit', async (event) => { + event.preventDefault(); + result.textContent = 'Отправляем заявку...'; + + const data = Object.fromEntries(new FormData(form).entries()); + const response = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const payload = await response.json(); + + if (!payload.ok) { + result.textContent = payload.error || 'Ошибка регистрации'; + return; + } + + const payment = payload.data.payment || {}; + const lines = [ + `Заявка создана: ${payload.data.callsign}`, + `Статус: ${payload.data.status}`, + ]; + if (payment.confirmation_url) { + lines.push(`YooKassa: ${payment.confirmation_url}`); + } + if (payment.manual_transfer?.instructions) { + lines.push(`Перевод по чеку: ${payment.manual_transfer.instructions}`); + lines.push(`ID платежа для чека: ${payment.id}`); + } + + result.textContent = lines.join('\n'); +}); diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..1567038 --- /dev/null +++ b/public/assets/styles.css @@ -0,0 +1,244 @@ +:root { + color-scheme: dark; + --bg: #07080d; + --panel: #111522; + --panel-2: #171d2d; + --text: #f6f7fb; + --muted: #aeb7c9; + --line: #2a3347; + --accent: #5ee0b8; + --accent-2: #88a7ff; + --danger: #ff7d7d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top left, #17253c 0, transparent 34rem), var(--bg); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; +} + +a { + color: inherit; +} + +.shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 36px 0 56px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); + gap: 28px; + align-items: stretch; + min-height: 420px; +} + +.hero.compact { + min-height: 260px; + grid-template-columns: 1fr; +} + +.eyebrow { + margin: 0 0 18px; + color: var(--accent); + font-size: 14px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + line-height: .98; +} + +h1 { + max-width: 900px; + font-size: clamp(44px, 7vw, 86px); + letter-spacing: 0; +} + +h2 { + font-size: 32px; +} + +.lead { + max-width: 720px; + margin: 22px 0 0; + color: var(--muted); + font-size: 22px; + line-height: 1.35; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 28px; +} + +.button { + border: 0; + border-radius: 8px; + padding: 14px 20px; + background: var(--accent); + color: #04120f; + font: inherit; + font-weight: 800; + text-decoration: none; + cursor: pointer; +} + +.button.ghost { + border: 1px solid var(--line); + background: transparent; + color: var(--text); +} + +.status-panel, +.tile, +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--panel) 92%, transparent); +} + +.status-panel { + display: grid; + align-content: end; + gap: 14px; + padding: 22px; +} + +.status-panel div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.status-panel div:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.status-panel span, +.tile p, +label { + color: var(--muted); +} + +.status-panel strong { + font-size: 34px; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 24px; +} + +.tile { + padding: 22px; +} + +.num { + display: block; + margin-bottom: 34px; + color: var(--accent-2); + font-size: 18px; + font-weight: 800; +} + +.tile p { + margin: 16px 0 0; + font-size: 18px; + line-height: 1.45; +} + +.panel { + margin-top: 24px; + padding: 24px; +} + +.panel.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, .8fr); + gap: 24px; +} + +.form, +.settings-form { + display: grid; + gap: 14px; +} + +.settings-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +label { + display: grid; + gap: 8px; + font-size: 14px; + font-weight: 700; +} + +input { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + background: var(--panel-2); + color: var(--text); + font: inherit; + font-size: 17px; +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.result { + min-height: 72px; + overflow: auto; + margin: 0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + background: #06070b; + color: var(--text); + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; +} + +.env-preview { + min-height: 420px; +} + +@media (max-width: 880px) { + .hero, + .grid, + .panel.split, + .settings-form { + grid-template-columns: 1fr; + } + + h1 { + font-size: 44px; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..bd427b4 --- /dev/null +++ b/public/index.php @@ -0,0 +1,123 @@ +pdo(); + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; + +try { + if ($method === 'GET' && ($path === '/' || $path === '/dashboard')) { + require __DIR__ . '/views/home.php'; + exit; + } + + if ($method === 'GET' && $path === '/admin') { + require __DIR__ . '/views/admin.php'; + exit; + } + + if ($method === 'GET' && $path === '/api/health') { + Response::json(['ok' => true, 'service' => 'xlx-panel']); + } + + if ($method === 'GET' && $path === '/api/callsigns/check') { + $paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + $service = new RegistrationService($config, $paymentService); + Response::json(['ok' => true, 'data' => $service->checkCallsign($pdo, (string) ($_GET['callsign'] ?? ''))]); + } + + if ($method === 'POST' && $path === '/api/register') { + $paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + $service = new RegistrationService($config, $paymentService); + Response::json(['ok' => true, 'data' => $service->register($pdo, Input::json())], 201); + } + + if ($method === 'POST' && $path === '/api/payments/receipt') { + $input = Input::json(); + $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + Response::json(['ok' => true, 'data' => $service->submitReceipt( + $pdo, + (int) ($input['payment_id'] ?? 0), + (int) ($input['user_id'] ?? 0), + (string) ($input['receipt_reference'] ?? ''), + isset($input['comment']) ? (string) $input['comment'] : null, + )], 201); + } + + if ($method === 'POST' && $path === '/api/admin/payments/confirm') { + Security::requireAdminToken($config); + $input = Input::json(); + $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + Response::json(['ok' => true, 'data' => $service->confirm($pdo, (int) ($input['payment_id'] ?? 0), $input['admin_user_id'] ?? null)]); + } + + if ($method === 'POST' && $path === '/api/admin/receipts/approve') { + Security::requireAdminToken($config); + $input = Input::json(); + $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + Response::json(['ok' => true, 'data' => $service->approveReceipt($pdo, (int) ($input['receipt_id'] ?? 0), $input['admin_user_id'] ?? null)]); + } + + if ($method === 'POST' && $path === '/api/webhooks/yookassa') { + $token = $_SERVER['HTTP_X_WEBHOOK_TOKEN'] ?? ($_GET['token'] ?? ''); + if (!hash_equals((string) $config->get('billing.yookassa.webhook_secret'), (string) $token)) { + Response::error('Webhook token is missing or invalid.', 401); + } + + $input = Input::json(); + if (($input['event'] ?? '') !== 'payment.succeeded') { + Response::json(['ok' => true, 'ignored' => true]); + } + + $providerPaymentId = (string) ($input['object']['id'] ?? ''); + $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config)); + Response::json(['ok' => true, 'data' => $service->confirmProviderPayment($pdo, $providerPaymentId)]); + } + + if ($method === 'GET' && $path === '/api/admin/access/export') { + Security::requireAdminToken($config); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="xlx-active-users.csv"'); + echo (new AccessExportService())->activeUsersCsv($pdo); + exit; + } + + if ($method === 'GET' && $path === '/api/admin/xlxd/settings') { + Security::requireAdminToken($config); + $service = new XlxdSettingsService($config); + $settings = $service->current($pdo); + Response::json([ + 'ok' => true, + 'data' => [ + 'settings' => $settings, + 'env' => $service->renderEnv($settings), + ], + ]); + } + + if ($method === 'POST' && $path === '/api/admin/xlxd/settings') { + Security::requireAdminToken($config); + $input = Input::json(); + $service = new XlxdSettingsService($config); + Response::json(['ok' => true, 'data' => $service->save($pdo, $input['settings'] ?? [], (bool) ($input['apply'] ?? false))]); + } + + 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 new file mode 100644 index 0000000..bd4fac8 --- /dev/null +++ b/public/views/admin.php @@ -0,0 +1,87 @@ + + + + + + XLX Admin + + + +
+
+
+

Админка

+

Настройка XLX без ручного редактирования конфигов

+

Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.

+
+
+ +
+ +
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + +
+
+

Предпросмотр env

+

+            

+        
+
+
+ + + diff --git a/public/views/home.php b/public/views/home.php new file mode 100644 index 0000000..3dabf73 --- /dev/null +++ b/public/views/home.php @@ -0,0 +1,78 @@ + + + + + + XLX Server + + + +
+
+
+

XLX / DMR / Pi-Star

+

Личный доступ к DMR-серверу без ручной настройки

+

Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.

+ +
+
+
+ DMR порт + 62030 +
+
+ Модуль + A +
+
+ Оплата + YooKassa +
+
+
+ +
+
+ 01 +

Проверка позывного

+

Система проверяет формат и занятость позывного во внутренней базе.

+
+
+ 02 +

Оплата доступа

+

Платеж через YooKassa или перевод с отправкой чека на подтверждение.

+
+
+ 03 +

Данные Pi-Star

+

После оплаты выдаются сервер, порт, логин, пароль, DMR ID и модуль.

+
+
+ +
+
+

Регистрация

+

Создать заявку

+

После отправки появится ссылка YooKassa и инструкция для оплаты переводом.

+
+
+ + + + +

+        
+
+
+ + + diff --git a/scripts/firewall-ufw.sh b/scripts/firewall-ufw.sh new file mode 100644 index 0000000..79a4dcb --- /dev/null +++ b/scripts/firewall-ufw.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw allow 8880/udp +ufw allow 10001/udp +ufw allow 10002/udp +ufw allow 30001/udp +ufw allow 30051/udp +ufw allow 42000/udp +ufw allow 62030/udp + +ufw status verbose diff --git a/scripts/install-system.sh b/scripts/install-system.sh new file mode 100644 index 0000000..aecc464 --- /dev/null +++ b/scripts/install-system.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run as root: sudo bash scripts/install-system.sh" + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PUBLIC_DIR="$REPO_ROOT/public" + +detect_host() { + if [[ -n "${XLX_PUBLIC_HOST:-}" ]]; then + echo "$XLX_PUBLIC_HOST" + return + fi + + local fqdn + fqdn="$(hostname -f 2>/dev/null || true)" + if [[ -n "$fqdn" && "$fqdn" != "localhost" && "$fqdn" != "localhost.localdomain" ]]; then + echo "$fqdn" + return + fi + + local ip + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + if [[ -n "$ip" ]]; then + echo "$ip" + return + fi + + echo "127.0.0.1" +} + +random_token() { + local token + token="$(tr -dc 'A-Za-z0-9' "$REPO_ROOT/config/config.php" < /etc/xlx/xlxd.env < /etc/apache2/sites-available/xlx-panel.conf +a2dissite 000-default.conf >/dev/null 2>&1 || true +a2ensite xlx-panel.conf +systemctl reload apache2 + +bash "$REPO_ROOT/scripts/install-xlxd.sh" +systemctl start xlxd || true + +cat < "$module_file" + local i char + for ((i = 0; i < ${#XLX_MODULES}; i++)); do + char="${XLX_MODULES:$i:1}" + printf "%s\n" "$char" >> "$module_file" + done +} + +start() { + render_modules + if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "xlxd already running with pid $(cat "$PID_FILE")" + exit 0 + fi + + mkdir -p "$(dirname "$XLX_LOG_PATH")" + cd "$XLX_INSTALL_PATH" + ./xlxd -d -p "$PID_FILE" >> "$XLX_LOG_PATH" 2>&1 +} + +stop() { + if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + kill "$(cat "$PID_FILE")" + rm -f "$PID_FILE" + return + fi + + pkill -f "$XLX_INSTALL_PATH/xlxd" || true + rm -f "$PID_FILE" +} + +status() { + if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "xlxd is running with pid $(cat "$PID_FILE")" + exit 0 + fi + echo "xlxd is not running" + exit 3 +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + render-modules) render_modules ;; + *) usage; exit 2 ;; +esac diff --git a/src/Domain/AccessCredentialService.php b/src/Domain/AccessCredentialService.php new file mode 100644 index 0000000..f7664db --- /dev/null +++ b/src/Domain/AccessCredentialService.php @@ -0,0 +1,44 @@ +config->get('security.dmr_password_length', 16); + $password = Security::randomPassword($length); + $hash = password_hash($password, PASSWORD_DEFAULT); + $hint = Security::passwordHint($password); + + $sql = <<<'SQL' +INSERT INTO access_credentials (user_id, username, dmr_password_hash, dmr_password_hint, is_enabled, last_rotated_at) +VALUES (?, ?, ?, ?, 1, NOW()) +ON DUPLICATE KEY UPDATE + username = VALUES(username), + dmr_password_hash = VALUES(dmr_password_hash), + dmr_password_hint = VALUES(dmr_password_hint), + is_enabled = 1, + last_rotated_at = NOW() +SQL; + + $statement = $pdo->prepare($sql); + $statement->execute([$userId, $callsign, $hash, $hint]); + + return [ + 'username' => $callsign, + 'password' => $password, + 'password_hint' => $hint, + ]; + } +} diff --git a/src/Domain/AccessExportService.php b/src/Domain/AccessExportService.php new file mode 100644 index 0000000..f0ce963 --- /dev/null +++ b/src/Domain/AccessExportService.php @@ -0,0 +1,33 @@ +query( + "SELECT u.callsign, d.dmr_id, c.username, c.dmr_password_hint, u.access_expires_at + FROM users u + JOIN dmr_ids d ON d.user_id = u.id + JOIN access_credentials c ON c.user_id = u.id + WHERE u.status = 'active' + AND c.is_enabled = 1 + AND (u.access_expires_at IS NULL OR u.access_expires_at > NOW()) + ORDER BY u.callsign" + )->fetchAll(); + + $handle = fopen('php://temp', 'r+'); + fputcsv($handle, ['callsign', 'dmr_id', 'username', 'password_hint', 'access_expires_at']); + foreach ($rows as $row) { + fputcsv($handle, $row); + } + rewind($handle); + + return (string) stream_get_contents($handle); + } +} diff --git a/src/Domain/IdAllocator.php b/src/Domain/IdAllocator.php new file mode 100644 index 0000000..795e482 --- /dev/null +++ b/src/Domain/IdAllocator.php @@ -0,0 +1,47 @@ +prepare('SELECT dmr_id FROM dmr_ids WHERE user_id = ? LIMIT 1'); + $existing->execute([$userId]); + $dmrId = $existing->fetchColumn(); + if ($dmrId !== false) { + return (int) $dmrId; + } + + $min = (int) $this->config->get('id_allocator.min_id', 9000001); + $max = (int) $this->config->get('id_allocator.max_id', 9099999); + + $next = $pdo->query('SELECT COALESCE(MAX(dmr_id), 0) + 1 FROM dmr_ids')->fetchColumn(); + $candidate = max($min, (int) $next); + + while ($candidate <= $max) { + try { + $insert = $pdo->prepare('INSERT INTO dmr_ids (user_id, dmr_id, status) VALUES (?, ?, ?)'); + $insert->execute([$userId, $candidate, 'active']); + return $candidate; + } catch (\PDOException $exception) { + if ($exception->getCode() !== '23000') { + throw $exception; + } + $candidate++; + } + } + + throw new RuntimeException('DMR ID range is exhausted.'); + } +} diff --git a/src/Domain/PaymentService.php b/src/Domain/PaymentService.php new file mode 100644 index 0000000..21e94c4 --- /dev/null +++ b/src/Domain/PaymentService.php @@ -0,0 +1,244 @@ +query('SELECT * FROM tariffs WHERE is_active = 1 ORDER BY id ASC LIMIT 1')->fetch(); + if (!$tariff) { + return null; + } + + $provider = (string) $this->config->get('billing.mode', 'manual'); + $statement = $pdo->prepare('INSERT INTO payments (user_id, tariff_id, amount, currency, provider, status) VALUES (?, ?, ?, ?, ?, ?)'); + $statement->execute([ + $userId, + $tariff['id'], + $tariff['price'], + $tariff['currency'], + $provider, + 'pending', + ]); + + $paymentId = (int) $pdo->lastInsertId(); + $result = [ + 'id' => $paymentId, + 'amount' => (float) $tariff['price'], + 'currency' => $tariff['currency'], + 'tariff' => $tariff['name'], + 'provider' => $provider, + ]; + + if ($provider === 'yookassa' && $this->yooKassaClient !== null) { + $user = $this->findUser($pdo, $userId); + $created = $this->yooKassaClient->createPayment([ + 'id' => $paymentId, + 'amount' => $tariff['price'], + 'currency' => $tariff['currency'], + ], $user, $tariff); + + $confirmationUrl = $created['confirmation']['confirmation_url'] ?? null; + $pdo->prepare('UPDATE payments SET provider_payment_id = ?, confirmation_url = ? WHERE id = ?') + ->execute([$created['id'] ?? null, $confirmationUrl, $paymentId]); + + $result['provider_payment_id'] = $created['id'] ?? null; + $result['confirmation_url'] = $confirmationUrl; + } + + if ((bool) $this->config->get('billing.manual_transfer_enabled', false)) { + $result['manual_transfer'] = [ + 'enabled' => true, + 'instructions' => $this->config->get('billing.manual_transfer_instructions'), + ]; + } + + return $result; + } + + public function confirm(PDO $pdo, int $paymentId, ?int $adminUserId = null): array + { + $pdo->beginTransaction(); + try { + $paymentStatement = $pdo->prepare( + 'SELECT p.*, t.duration_days, u.callsign FROM payments p JOIN users u ON u.id = p.user_id LEFT JOIN tariffs t ON t.id = p.tariff_id WHERE p.id = ? FOR UPDATE' + ); + $paymentStatement->execute([$paymentId]); + $payment = $paymentStatement->fetch(); + if (!$payment) { + throw new RuntimeException('Payment was not found.'); + } + if ($payment['status'] === 'paid') { + $pdo->commit(); + return $this->currentAccess($pdo, (int) $payment['user_id']); + } + if ($payment['status'] !== 'pending') { + throw new RuntimeException('Payment is not pending.'); + } + + $durationDays = (int) ($payment['duration_days'] ?? $this->config->get('billing.default_duration_days', 30)); + $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $durationDays . ' days')->format('Y-m-d H:i:s'); + + $pdo->prepare('UPDATE payments SET status = ?, paid_at = NOW() WHERE id = ?')->execute(['paid', $paymentId]); + $pdo->prepare('UPDATE users SET status = ?, access_expires_at = ? WHERE id = ?')->execute(['active', $expiresAt, $payment['user_id']]); + + $dmrId = $this->idAllocator->allocate($pdo, (int) $payment['user_id']); + $credential = $this->accessCredentialService->enableForUser($pdo, (int) $payment['user_id'], $payment['callsign']); + + $pdo->prepare( + 'INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, payload_json) VALUES (?, ?, ?, ?, ?)' + )->execute([ + $adminUserId, + 'payment.confirmed', + 'payment', + $paymentId, + json_encode(['user_id' => (int) $payment['user_id'], 'dmr_id' => $dmrId], JSON_UNESCAPED_SLASHES), + ]); + + $pdo->commit(); + + return [ + 'user_id' => (int) $payment['user_id'], + 'callsign' => $payment['callsign'], + 'dmr_id' => $dmrId, + 'access_expires_at' => $expiresAt, + 'connection' => [ + 'server_host' => $this->config->get('xlx.server_host'), + 'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030), + 'default_module' => $this->config->get('xlx.default_module', 'A'), + 'username' => $credential['username'], + 'password' => $credential['password'], + ], + ]; + } catch (\Throwable $throwable) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $throwable; + } + } + + public function submitReceipt(PDO $pdo, int $paymentId, int $userId, string $receiptReference, ?string $comment): array + { + $payment = $pdo->prepare('SELECT id, user_id, status FROM payments WHERE id = ? AND user_id = ? LIMIT 1'); + $payment->execute([$paymentId, $userId]); + $row = $payment->fetch(); + if (!$row) { + throw new RuntimeException('Payment was not found for this user.'); + } + if ($row['status'] !== 'pending') { + throw new RuntimeException('Only pending payments can receive receipts.'); + } + if (trim($receiptReference) === '') { + throw new RuntimeException('Receipt reference is required.'); + } + + $statement = $pdo->prepare( + 'INSERT INTO payment_receipts (payment_id, user_id, receipt_reference, comment) VALUES (?, ?, ?, ?)' + ); + $statement->execute([$paymentId, $userId, trim($receiptReference), $comment]); + + return [ + 'receipt_id' => (int) $pdo->lastInsertId(), + 'payment_id' => $paymentId, + 'status' => 'pending', + ]; + } + + public function approveReceipt(PDO $pdo, int $receiptId, ?int $adminUserId = null): array + { + $receipt = $pdo->prepare('SELECT * FROM payment_receipts WHERE id = ? LIMIT 1'); + $receipt->execute([$receiptId]); + $row = $receipt->fetch(); + if (!$row) { + throw new RuntimeException('Receipt was not found.'); + } + if ($row['status'] !== 'pending') { + throw new RuntimeException('Receipt is not pending.'); + } + + $result = $this->confirm($pdo, (int) $row['payment_id'], $adminUserId); + + $pdo->prepare('UPDATE payment_receipts SET status = ?, reviewed_by_user_id = ?, reviewed_at = NOW() WHERE id = ?') + ->execute(['approved', $adminUserId, $receiptId]); + + return $result; + } + + public function confirmProviderPayment(PDO $pdo, string $providerPaymentId): array + { + if ($this->yooKassaClient !== null) { + $providerPayment = $this->yooKassaClient->fetchPayment($providerPaymentId); + if (($providerPayment['status'] ?? null) !== 'succeeded' || ($providerPayment['paid'] ?? false) !== true) { + throw new RuntimeException('YooKassa payment is not succeeded.'); + } + } + + $statement = $pdo->prepare('SELECT id FROM payments WHERE provider = ? AND provider_payment_id = ? LIMIT 1'); + $statement->execute(['yookassa', $providerPaymentId]); + $paymentId = $statement->fetchColumn(); + if ($paymentId === false) { + throw new RuntimeException('Local payment was not found.'); + } + + return $this->confirm($pdo, (int) $paymentId); + } + + private function findUser(PDO $pdo, int $userId): array + { + $statement = $pdo->prepare('SELECT id, callsign, email FROM users WHERE id = ? LIMIT 1'); + $statement->execute([$userId]); + $user = $statement->fetch(); + if (!$user) { + throw new RuntimeException('User was not found.'); + } + + return $user; + } + + private function currentAccess(PDO $pdo, int $userId): array + { + $statement = $pdo->prepare( + 'SELECT u.id AS user_id, u.callsign, u.access_expires_at, d.dmr_id, c.username, c.dmr_password_hint + FROM users u + LEFT JOIN dmr_ids d ON d.user_id = u.id + LEFT JOIN access_credentials c ON c.user_id = u.id + WHERE u.id = ? + LIMIT 1' + ); + $statement->execute([$userId]); + $row = $statement->fetch(); + if (!$row) { + throw new RuntimeException('User was not found.'); + } + + return [ + 'user_id' => (int) $row['user_id'], + 'callsign' => $row['callsign'], + 'dmr_id' => $row['dmr_id'] !== null ? (int) $row['dmr_id'] : null, + 'access_expires_at' => $row['access_expires_at'], + 'connection' => [ + 'server_host' => $this->config->get('xlx.server_host'), + 'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030), + 'default_module' => $this->config->get('xlx.default_module', 'A'), + 'username' => $row['username'], + 'password_hint' => $row['dmr_password_hint'], + ], + ]; + } +} diff --git a/src/Domain/RegistrationService.php b/src/Domain/RegistrationService.php new file mode 100644 index 0000000..e5bd557 --- /dev/null +++ b/src/Domain/RegistrationService.php @@ -0,0 +1,122 @@ + $callsign, + 'available' => false, + 'status' => 'invalid_format', + 'message' => 'Invalid callsign format.', + ]; + } + + $statement = $pdo->prepare('SELECT id FROM users WHERE callsign = ? LIMIT 1'); + $statement->execute([$callsign]); + + return [ + 'callsign' => $callsign, + 'available' => $statement->fetch() === false, + 'status' => 'internal_registry', + 'message' => 'Checked against local registered users.', + ]; + } + + public function register(PDO $pdo, array $input): array + { + $callsign = Security::normalizeCallsign((string) ($input['callsign'] ?? '')); + $email = strtolower(trim((string) ($input['email'] ?? ''))); + $password = (string) ($input['password'] ?? ''); + + if (!Security::isValidCallsign($callsign)) { + throw new RuntimeException('Invalid callsign format.'); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Invalid email address.'); + } + if (strlen($password) < (int) $this->config->get('security.password_min_length', 10)) { + throw new RuntimeException('Password is too short.'); + } + + $callsignCheck = $this->checkCallsign($pdo, $callsign); + if (!$callsignCheck['available']) { + throw new RuntimeException('Callsign is already registered.'); + } + + $emailCheck = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); + $emailCheck->execute([$email]); + if ($emailCheck->fetch()) { + throw new RuntimeException('Email is already registered.'); + } + + $verification = [ + 'status' => 'verified', + 'source' => 'internal_registry', + 'payload' => [ + 'message' => 'Callsign is not present in local registered users.', + ], + ]; + $status = 'payment_pending'; + + $pdo->beginTransaction(); + try { + $insertUser = $pdo->prepare( + 'INSERT INTO users (callsign, email, password_hash, status) VALUES (?, ?, ?, ?)' + ); + $insertUser->execute([$callsign, $email, password_hash($password, PASSWORD_DEFAULT), $status]); + $userId = (int) $pdo->lastInsertId(); + + $insertCheck = $pdo->prepare( + 'INSERT INTO callsign_checks (user_id, callsign, source, status, response_json, checked_at) VALUES (?, ?, ?, ?, ?, NOW())' + ); + $insertCheck->execute([ + $userId, + $callsign, + $verification['source'], + $verification['status'], + json_encode($verification['payload'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + $payment = null; + if ($status === 'payment_pending') { + $payment = $this->paymentService->createPendingPayment($pdo, $userId); + } + + $pdo->commit(); + + return [ + 'user_id' => $userId, + 'callsign' => $callsign, + 'status' => $status, + 'verification' => [ + 'status' => $verification['status'], + 'source' => $verification['source'], + ], + 'payment' => $payment, + ]; + } catch (\Throwable $throwable) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $throwable; + } + } +} diff --git a/src/Domain/XlxdSettingsService.php b/src/Domain/XlxdSettingsService.php new file mode 100644 index 0000000..ab7241a --- /dev/null +++ b/src/Domain/XlxdSettingsService.php @@ -0,0 +1,153 @@ + 'XLX_REFLECTOR_NAME', + 'server_host' => 'XLX_SERVER_HOST', + 'sysop_callsign' => 'XLX_SYSOP_CALLSIGN', + 'sysop_email' => 'XLX_SYSOP_EMAIL', + 'country' => 'XLX_COUNTRY', + 'dashboard_port' => 'XLX_DASHBOARD_PORT', + 'dmr_port' => 'XLX_DMR_PORT', + 'ysf_port' => 'XLX_YSF_PORT', + 'default_module' => 'XLX_DEFAULT_MODULE', + 'modules' => 'XLX_MODULES', + 'install_path' => 'XLX_INSTALL_PATH', + 'source_path' => 'XLX_SOURCE_PATH', + 'log_path' => 'XLX_LOG_PATH', + 'service_name' => 'XLX_SERVICE_NAME', + 'repo_url' => 'XLX_REPO_URL', + ]; + + public function __construct(private readonly Config $config) + { + } + + public function current(PDO $pdo): array + { + $settings = [ + 'reflector_name' => $this->config->get('xlx.reflector_name', 'XLX000'), + 'server_host' => $this->config->get('xlx.server_host', 'xlx.example.com'), + 'sysop_callsign' => $this->config->get('xlx.sysop_callsign', 'N0CALL'), + 'sysop_email' => $this->config->get('xlx.sysop_email', 'admin@example.com'), + 'country' => $this->config->get('xlx.country', 'RU'), + 'dashboard_port' => (string) $this->config->get('xlx.dashboard_port', 8080), + 'dmr_port' => (string) $this->config->get('xlx.dmr_port', 62030), + 'ysf_port' => (string) $this->config->get('xlx.ysf_port', 42000), + 'default_module' => $this->config->get('xlx.default_module', 'A'), + 'modules' => $this->config->get('xlx.modules', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + 'install_path' => $this->config->get('xlx.install_path', '/xlxd'), + 'source_path' => $this->config->get('xlx.source_path', '/usr/src/xlxd'), + 'log_path' => $this->config->get('xlx.log_path', '/var/log/xlxd.log'), + 'service_name' => $this->config->get('xlx.service_name', 'xlxd'), + 'repo_url' => 'https://github.com/LX3JL/xlxd.git', + ]; + + $rows = $pdo->query("SELECT setting_key, setting_value FROM server_settings WHERE setting_key LIKE 'xlxd.%'")->fetchAll(); + foreach ($rows as $row) { + $key = substr((string) $row['setting_key'], 5); + if (array_key_exists($key, $settings)) { + $settings[$key] = (string) $row['setting_value']; + } + } + + return $settings; + } + + public function save(PDO $pdo, array $input, bool $apply): array + { + $settings = $this->current($pdo); + foreach (self::FIELD_MAP as $key => $_) { + if (array_key_exists($key, $input)) { + $settings[$key] = $this->sanitize($key, (string) $input[$key]); + } + } + + $statement = $pdo->prepare( + 'INSERT INTO server_settings (setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()' + ); + foreach ($settings as $key => $value) { + $statement->execute(['xlxd.' . $key, $value]); + } + + $env = $this->renderEnv($settings); + $applied = false; + $message = 'Settings saved to database.'; + if ($apply) { + $path = rtrim((string) $this->config->get('xlx.config_path', '/etc/xlx'), '/') . '/xlxd.env'; + $this->writeEnvFile($path, $env); + $applied = true; + $message = 'Settings saved and xlxd.env updated.'; + } + + return [ + 'settings' => $settings, + 'env' => $env, + 'applied' => $applied, + 'message' => $message, + ]; + } + + public function renderEnv(array $settings): string + { + $lines = []; + foreach (self::FIELD_MAP as $key => $envKey) { + $lines[] = $envKey . '=' . $this->escapeEnv((string) ($settings[$key] ?? '')); + } + + return implode("\n", $lines) . "\n"; + } + + private function sanitize(string $key, string $value): string + { + $value = trim($value); + if (in_array($key, ['dashboard_port', 'dmr_port', 'ysf_port'], true)) { + $port = (int) $value; + if ($port < 1 || $port > 65535) { + throw new RuntimeException('Invalid port for ' . $key . '.'); + } + return (string) $port; + } + if ($key === 'reflector_name' && !preg_match('/^XLX[A-Z0-9]{3}$/', $value)) { + throw new RuntimeException('Reflector name must look like XLX138.'); + } + if ($key === 'default_module' && !preg_match('/^[A-Z]$/', $value)) { + throw new RuntimeException('Default module must be A-Z.'); + } + if ($key === 'modules' && !preg_match('/^[A-Z]+$/', $value)) { + throw new RuntimeException('Modules must contain only letters A-Z.'); + } + + return $value; + } + + private function writeEnvFile(string $path, string $env): void + { + $directory = dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new RuntimeException('Cannot create config directory: ' . $directory); + } + if (file_put_contents($path, $env, LOCK_EX) === false) { + throw new RuntimeException('Cannot write xlxd env file: ' . $path); + } + } + + private function escapeEnv(string $value): string + { + if ($value === '' || preg_match('/\s/', $value) || strpbrk($value, '"\'\\$`') !== false) { + return '"' . str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $value) . '"'; + } + + return $value; + } +} diff --git a/src/Domain/YooKassaClient.php b/src/Domain/YooKassaClient.php new file mode 100644 index 0000000..17eb735 --- /dev/null +++ b/src/Domain/YooKassaClient.php @@ -0,0 +1,91 @@ + [ + 'value' => number_format((float) $payment['amount'], 2, '.', ''), + 'currency' => $payment['currency'], + ], + 'capture' => (bool) $this->config->get('billing.yookassa.capture', true), + 'confirmation' => [ + 'type' => 'redirect', + 'return_url' => $this->config->get('billing.yookassa.return_url'), + ], + 'description' => sprintf('XLX доступ для %s, платеж #%d', $user['callsign'], $payment['id']), + 'metadata' => [ + 'payment_id' => (string) $payment['id'], + 'user_id' => (string) $user['id'], + 'callsign' => $user['callsign'], + 'tariff_id' => (string) $tariff['id'], + ], + ]; + + return $this->request('POST', '/payments', $payload, 'xlx-payment-' . $payment['id']); + } + + public function fetchPayment(string $providerPaymentId): array + { + return $this->request('GET', '/payments/' . rawurlencode($providerPaymentId)); + } + + private function request(string $method, string $path, ?array $payload = null, ?string $idempotenceKey = null): array + { + $shopId = (string) $this->config->get('billing.yookassa.shop_id'); + $secretKey = (string) $this->config->get('billing.yookassa.secret_key'); + if ($shopId === '' || $shopId === 'change_me' || $secretKey === '' || $secretKey === 'change_me') { + throw new RuntimeException('YooKassa credentials are not configured.'); + } + + $headers = [ + 'Authorization: Basic ' . base64_encode($shopId . ':' . $secretKey), + 'Content-Type: application/json', + 'Accept: application/json', + ]; + if ($idempotenceKey !== null) { + $headers[] = 'Idempotence-Key: ' . substr($idempotenceKey, 0, 64); + } + + $options = [ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers) . "\r\n", + 'ignore_errors' => true, + 'timeout' => 15, + ], + ]; + if ($payload !== null) { + $options['http']['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $body = @file_get_contents(self::API_URL . $path, false, stream_context_create($options)); + if ($body === false) { + throw new RuntimeException('YooKassa request failed.'); + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new RuntimeException('YooKassa returned invalid JSON.'); + } + if (isset($decoded['type']) && $decoded['type'] === 'error') { + throw new RuntimeException((string) ($decoded['description'] ?? 'YooKassa error.')); + } + + return $decoded; + } +} diff --git a/src/Support/Config.php b/src/Support/Config.php new file mode 100644 index 0000000..c77e979 --- /dev/null +++ b/src/Support/Config.php @@ -0,0 +1,25 @@ +values; + foreach (explode('.', $key) as $part) { + if (!is_array($value) || !array_key_exists($part, $value)) { + return $default; + } + $value = $value[$part]; + } + + return $value; + } +} diff --git a/src/Support/Database.php b/src/Support/Database.php new file mode 100644 index 0000000..3b369ae --- /dev/null +++ b/src/Support/Database.php @@ -0,0 +1,32 @@ +config->get('database.charset', 'utf8mb4'); + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $this->config->get('database.host', '127.0.0.1'), + (int) $this->config->get('database.port', 3306), + $this->config->get('database.name'), + $charset + ); + + return new PDO($dsn, (string) $this->config->get('database.user'), (string) $this->config->get('database.password'), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } +} diff --git a/src/Support/Input.php b/src/Support/Input.php new file mode 100644 index 0000000..e2dfb80 --- /dev/null +++ b/src/Support/Input.php @@ -0,0 +1,23 @@ + false, 'error' => $message] + $extra, $status); + } +} diff --git a/src/Support/Security.php b/src/Support/Security.php new file mode 100644 index 0000000..286c63c --- /dev/null +++ b/src/Support/Security.php @@ -0,0 +1,46 @@ +get('admin.api_token', ''); + $actual = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? ''; + + if ($expected === '' || !hash_equals($expected, $actual)) { + Response::error('Admin token is missing or invalid.', 401); + } + } +}