diff --git a/.gitignore b/.gitignore index 678b454..bc4673f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ Thumbs.db .vscode/ # Local secrets -deploy-config.php config.php +secrets.php # Logs/temp *.log diff --git a/README.md b/README.md index b21d6e7..98cd544 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,16 @@ photo.andr33v.ru/ ├─ admin.php # админка по токену ├─ index-mysql.php # alias -> index.php ├─ admin-mysql.php # alias -> admin.php -├─ deploy.php # webhook деплоя ├─ style.css # базовые стили ├─ favicon.svg -├─ config.php.example # шаблон конфига БД -├─ deploy-config.php.example # шаблон токена/настроек деплоя +├─ config.php.example # шаблон конфига БД и деплоя +├─ secrets.php.example # шаблон секретов админки ├─ lib/ │ ├─ db.php # PDO + загрузка config.php │ ├─ db_gallery.php # доступ к данным галереи │ ├─ thumbs.php # генерация/чтение/удаление превью │ ├─ admin_http.php # JSON-ответы админки +│ ├─ admin_deploy.php # проверка обновлений и запуск деплоя │ ├─ admin_get_actions.php # GET-экшены админки │ ├─ admin_post_actions.php # POST-экшены админки │ └─ admin_helpers.php # helper-функции админки @@ -87,36 +87,49 @@ cp config.php.example config.php 2. Заполни доступы к БД в `config.php`. -3. Прогон миграций: +3. Создай `secrets.php`: + +```bash +cp secrets.php.example secrets.php +``` + +4. Заполни минимум `admin_token` в `secrets.php`. + +5. Прогон миграций: ```bash php scripts/migrate.php ``` -4. Локальный запуск: +6. Локальный запуск: ```bash php -S 127.0.0.1:8080 ``` -5. Открой: +7. Открой: - `http://127.0.0.1:8080` — публичная часть, -- `http://127.0.0.1:8080/admin.php?token=` — админка (после настройки `deploy-config.php`). +- `http://127.0.0.1:8080/admin.php?token=` — админка (токен из `secrets.php`). ## Настройка админки -Админка использует `token` из `deploy-config.php`. +Админка использует секреты из `secrets.php`. Создай файл: ```bash -cp deploy-config.php.example deploy-config.php +cp secrets.php.example secrets.php ``` Минимально заполни: -- `token` — длинный случайный секрет. +- `admin_token` — длинный случайный секрет. + +Опционально можно ограничить доступ: + +- `basic_auth_user` / `basic_auth_pass` — HTTP Basic слой поверх админки, +- `allowed_admin_ips` — белый список IP (строгое совпадение строк). В админке доступны разделы: @@ -144,20 +157,22 @@ php scripts/generate_thumbs.php ## Деплой -Webhook: +Деплой запускается из админки (вкладка `Настройки`): -- `deploy.php?token=` +- кнопка `Проверить обновления` делает `git fetch` и сравнивает `HEAD` с `origin/`, +- если локальная ветка отстает и не расходится (`behind > 0`, `ahead = 0`) — показывается кнопка `Обновить проект`. Скрипт `scripts/deploy.sh`: 1. делает `git fetch --all --prune`, 2. переключает код на `origin/` через `git reset --hard`, -3. сохраняет runtime-папки (`photos`, `thumbs`, `data`). +3. запускает миграции `php scripts/migrate.php`, +4. сохраняет runtime-папки (`photos`, `thumbs`, `data`). Важно: деплой-скрипт перетирает рабочие изменения в репозитории на сервере. ## Примечания - Проект принудительно редиректит на HTTPS и non-www через `.htaccess`. -- Для production рекомендуется ограничить webhook по IP и/или Basic Auth (`deploy-config.php`). +- Для production рекомендуется включить IP whitelist и/или Basic Auth в `secrets.php`. - Если `config.php` отсутствует, приложение корректно падает с ошибкой подключения к БД. diff --git a/admin.php b/admin.php index f983731..484f440 100644 --- a/admin.php +++ b/admin.php @@ -6,19 +6,29 @@ require_once __DIR__ . '/lib/db_gallery.php'; require_once __DIR__ . '/lib/thumbs.php'; require_once __DIR__ . '/lib/admin_http.php'; require_once __DIR__ . '/lib/admin_helpers.php'; +require_once __DIR__ . '/lib/admin_deploy.php'; require_once __DIR__ . '/lib/admin_get_actions.php'; require_once __DIR__ . '/lib/admin_post_actions.php'; const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; -$configPath = __DIR__ . '/deploy-config.php'; -if (!is_file($configPath)) { +try { + $config = appConfig(); + $secrets = appSecrets(); +} catch (Throwable $e) { http_response_code(500); - exit('deploy-config.php not found'); + exit($e->getMessage()); } -$config = require $configPath; -$basicUser = (string)($config['basic_auth_user'] ?? ''); -$basicPass = (string)($config['basic_auth_pass'] ?? ''); + +$allowedAdminIps = array_values(array_filter(array_map(static fn($ip): string => trim((string)$ip), (array)($secrets['allowed_admin_ips'] ?? [])), static fn(string $ip): bool => $ip !== '')); +$clientIp = (string)($_SERVER['REMOTE_ADDR'] ?? ''); +if ($allowedAdminIps !== [] && !in_array($clientIp, $allowedAdminIps, true)) { + http_response_code(403); + exit('Forbidden'); +} + +$basicUser = (string)($secrets['basic_auth_user'] ?? ''); +$basicPass = (string)($secrets['basic_auth_pass'] ?? ''); if ($basicUser !== '' || $basicPass !== '') { $authUser = (string)($_SERVER['PHP_AUTH_USER'] ?? ''); @@ -30,13 +40,27 @@ if ($basicUser !== '' || $basicPass !== '') { } } -$tokenExpected = (string)($config['token'] ?? ''); +$tokenExpected = (string)($secrets['admin_token'] ?? ''); $tokenIncoming = (string)($_REQUEST['token'] ?? ''); if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) { http_response_code(403); exit('Forbidden'); } +$deployConfig = (array)($config['deploy'] ?? []); +$deployBranch = trim((string)($deployConfig['branch'] ?? 'main')); +if ($deployBranch === '') { + $deployBranch = 'main'; +} +$deployScript = trim((string)($deployConfig['script'] ?? (__DIR__ . '/scripts/deploy.sh'))); +if ($deployScript !== '' && !str_starts_with($deployScript, '/')) { + $deployScript = __DIR__ . '/' . ltrim($deployScript, '/'); +} +$deployPhpBin = trim((string)($deployConfig['php_bin'] ?? 'php')); +if ($deployPhpBin === '') { + $deployPhpBin = 'php'; +} + $requestAction = (string)($_REQUEST['action'] ?? ''); if ($_SERVER['REQUEST_METHOD'] === 'GET') { adminHandleGetAction($requestAction); @@ -44,6 +68,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $message = ''; $errors = []; +$deployStatus = null; +$deployOutput = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = (string)($_POST['action'] ?? ''); @@ -51,8 +77,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { || strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'; try { - $result = adminHandlePostAction($action, $isAjax, __DIR__); + $result = adminHandlePostAction($action, $isAjax, __DIR__, [ + 'branch' => $deployBranch, + 'script' => $deployScript, + 'php_bin' => $deployPhpBin, + ]); $message = (string)($result['message'] ?? ''); + $deployStatus = $result['deploy_status'] ?? null; + $deployOutput = (string)($result['deploy_output'] ?? ''); if (isset($result['errors']) && is_array($result['errors']) && $result['errors'] !== []) { $errors = array_merge($errors, $result['errors']); } @@ -134,6 +166,8 @@ function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $ .sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111} .sec a.active{background:#eef4ff;color:#1f6feb} .small{font-size:12px;color:#667085} + .deploy-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} + .deploy-output{margin-top:10px;padding:10px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;font-size:12px;white-space:pre-wrap;line-height:1.4;max-height:220px;overflow:auto} .inline-form{margin:0} .after-slot{display:flex;flex-direction:column;align-items:flex-start;gap:6px} .preview-actions{display:flex;gap:6px;margin-top:6px;flex-wrap:nowrap} @@ -242,6 +276,43 @@ function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $

+ +
+

Обновление проекта

+

Ветка:

+ + + + +
+
+ Локально: · origin/: · behind: · ahead: +
+ + +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ diff --git a/config.php.example b/config.php.example index b2a487f..1917285 100644 --- a/config.php.example +++ b/config.php.example @@ -8,4 +8,9 @@ return [ 'pass' => 'change_me', 'charset' => 'utf8mb4', ], + 'deploy' => [ + 'branch' => 'main', + 'script' => __DIR__ . '/scripts/deploy.sh', + 'php_bin' => 'php', + ], ]; diff --git a/deploy-config.php.example b/deploy-config.php.example deleted file mode 100644 index b5ec4ae..0000000 --- a/deploy-config.php.example +++ /dev/null @@ -1,25 +0,0 @@ - 'CHANGE_ME_TO_LONG_RANDOM_TOKEN', - - // Optional: HTTP Basic auth layer - 'basic_auth_user' => '', - 'basic_auth_pass' => '', - - // Optional: allow only these client IPs (empty = allow all) - 'allowed_ips' => [ - // '1.2.3.4', - ], - - // Deploy options - 'branch' => 'main', - 'deploy_script' => __DIR__ . '/scripts/deploy.sh', - - // Where to write webhook logs - 'log_file' => __DIR__ . '/data/deploy-webhook.log', -]; diff --git a/deploy.php b/deploy.php deleted file mode 100644 index c321ec3..0000000 --- a/deploy.php +++ /dev/null @@ -1,86 +0,0 @@ - $config */ -$config = require $configPath; -$tokenExpected = (string)($config['token'] ?? ''); -$allowedIps = (array)($config['allowed_ips'] ?? []); -$basicUser = (string)($config['basic_auth_user'] ?? ''); -$basicPass = (string)($config['basic_auth_pass'] ?? ''); -$branch = (string)($config['branch'] ?? 'main'); -$deployScript = (string)($config['deploy_script'] ?? (__DIR__ . '/scripts/deploy.sh')); -$logFile = (string)($config['log_file'] ?? (__DIR__ . '/data/deploy-webhook.log')); - -header('Content-Type: text/plain; charset=utf-8'); - -if ($basicUser !== '' || $basicPass !== '') { - $authUser = $_SERVER['PHP_AUTH_USER'] ?? ''; - $authPass = $_SERVER['PHP_AUTH_PW'] ?? ''; - if (!hash_equals($basicUser, (string)$authUser) || !hash_equals($basicPass, (string)$authPass)) { - header('WWW-Authenticate: Basic realm="Deploy"'); - http_response_code(401); - echo "Unauthorized\n"; - exit; - } -} - -$clientIp = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); -if ($allowedIps !== [] && !in_array($clientIp, $allowedIps, true)) { - http_response_code(403); - echo "Forbidden: IP not allowed\n"; - logLine($logFile, "DENY ip={$clientIp} reason=ip_not_allowed"); - exit; -} - -$tokenIncoming = (string)($_REQUEST['token'] ?? ''); -if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) { - http_response_code(403); - echo "Forbidden: invalid token\n"; - logLine($logFile, "DENY ip={$clientIp} reason=bad_token"); - exit; -} - -if (!is_file($deployScript)) { - http_response_code(500); - echo "Deploy script not found\n"; - logLine($logFile, "ERROR ip={$clientIp} reason=script_missing path={$deployScript}"); - exit; -} - -$cmd = 'BRANCH=' . escapeshellarg($branch) . ' bash ' . escapeshellarg($deployScript) . ' 2>&1'; -exec($cmd, $output, $code); - -$preview = implode("\n", array_slice($output, -30)); -logLine( - $logFile, - "RUN ip={$clientIp} code={$code} branch={$branch} output=" . str_replace(["\n", "\r"], ['\\n', ''], $preview) -); - -if ($code !== 0) { - http_response_code(500); - echo "Deploy failed\n\n"; - echo $preview . "\n"; - exit; -} - -echo "OK: deploy completed\n\n"; -echo $preview . "\n"; - -function logLine(string $path, string $line): void -{ - $dir = dirname($path); - if (!is_dir($dir)) { - @mkdir($dir, 0775, true); - } - $ts = date('Y-m-d H:i:s'); - @file_put_contents($path, "[{$ts}] {$line}\n", FILE_APPEND); -} diff --git a/lib/admin_deploy.php b/lib/admin_deploy.php new file mode 100644 index 0000000..9d08235 --- /dev/null +++ b/lib/admin_deploy.php @@ -0,0 +1,97 @@ + 0 && $ahead === 0) { + $state = 'update_available'; + } elseif ($ahead > 0 && $behind === 0) { + $state = 'local_ahead'; + } elseif ($ahead > 0 && $behind > 0) { + $state = 'diverged'; + } + + return [ + 'state' => $state, + 'branch' => $branch, + 'local_ref' => trim($local['output']), + 'remote_ref' => trim($remote['output']), + 'behind' => $behind, + 'ahead' => $ahead, + 'can_deploy' => $state === 'update_available', + ]; +} + +function adminRunDeployScript(string $projectRoot, string $branch, string $scriptPath, string $phpBin): array +{ + if (!is_file($scriptPath)) { + throw new RuntimeException('Скрипт деплоя не найден: ' . $scriptPath); + } + + $run = adminRunShellCommand('bash ' . escapeshellarg($scriptPath), $projectRoot, [ + 'BRANCH' => $branch, + 'PHP_BIN' => $phpBin, + ]); + + return [ + 'ok' => $run['code'] === 0, + 'code' => $run['code'], + 'output' => adminTailOutput($run['output']), + ]; +} + +function adminRunShellCommand(string $command, string $cwd, array $env = []): array +{ + $envPrefix = ''; + foreach ($env as $key => $value) { + if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', (string)$key)) { + continue; + } + $envPrefix .= $key . '=' . escapeshellarg((string)$value) . ' '; + } + + $fullCommand = 'cd ' . escapeshellarg($cwd) . ' && ' . $envPrefix . $command . ' 2>&1'; + $output = []; + $code = 0; + exec($fullCommand, $output, $code); + + return ['code' => $code, 'output' => implode("\n", $output)]; +} + +function adminTailOutput(string $output, int $maxLines = 80): string +{ + $output = trim($output); + if ($output === '') { + return ''; + } + + $lines = preg_split('/\r\n|\r|\n/', $output); + if (!is_array($lines)) { + return $output; + } + + return implode("\n", array_slice($lines, -$maxLines)); +} diff --git a/lib/admin_post_actions.php b/lib/admin_post_actions.php index 509b1fb..725737e 100644 --- a/lib/admin_post_actions.php +++ b/lib/admin_post_actions.php @@ -2,10 +2,12 @@ declare(strict_types=1); -function adminHandlePostAction(string $action, bool $isAjax, string $projectRoot): array +function adminHandlePostAction(string $action, bool $isAjax, string $projectRoot, array $deployOptions = []): array { $message = ''; $errors = []; + $deployStatus = null; + $deployOutput = ''; switch ($action) { case 'create_section': { @@ -141,6 +143,56 @@ function adminHandlePostAction(string $action, bool $isAjax, string $projectRoot break; } + case 'check_updates': { + $branch = (string)($deployOptions['branch'] ?? 'main'); + $deployStatus = adminCheckForUpdates($projectRoot, $branch); + $state = (string)($deployStatus['state'] ?? ''); + + if ($state === 'update_available') { + $message = 'Найдена новая версия. Можно обновиться.'; + } elseif ($state === 'up_to_date') { + $message = 'Обновлений нет: установлена актуальная версия.'; + } elseif ($state === 'local_ahead') { + $message = 'Локальная ветка опережает origin. Автообновление отключено.'; + } else { + $message = 'Ветка расходится с origin. Нужна ручная синхронизация.'; + } + + break; + } + + case 'deploy_updates': { + $branch = (string)($deployOptions['branch'] ?? 'main'); + $scriptPath = (string)($deployOptions['script'] ?? ($projectRoot . '/scripts/deploy.sh')); + $phpBin = (string)($deployOptions['php_bin'] ?? 'php'); + + $deployStatus = adminCheckForUpdates($projectRoot, $branch); + if (!(bool)($deployStatus['can_deploy'] ?? false)) { + $state = (string)($deployStatus['state'] ?? ''); + if ($state === 'up_to_date') { + $message = 'Обновление не требуется: уже актуальная версия.'; + break; + } + if ($state === 'local_ahead') { + throw new RuntimeException('Локальная ветка опережает origin. Автообновление отключено.'); + } + if ($state === 'diverged') { + throw new RuntimeException('Ветка расходится с origin. Выполни ручную синхронизацию.'); + } + throw new RuntimeException('Нельзя применить обновление в текущем состоянии ветки.'); + } + + $deployResult = adminRunDeployScript($projectRoot, $branch, $scriptPath, $phpBin); + $deployOutput = (string)($deployResult['output'] ?? ''); + if (!(bool)($deployResult['ok'] ?? false)) { + throw new RuntimeException('Деплой завершился с ошибкой: ' . ($deployOutput !== '' ? $deployOutput : ('код ' . (int)($deployResult['code'] ?? 1)))); + } + + $deployStatus = adminCheckForUpdates($projectRoot, $branch); + $message = 'Обновление выполнено.'; + break; + } + case 'upload_before_bulk': { $sectionId = (int)($_POST['section_id'] ?? 0); if ($sectionId < 1 || !sectionById($sectionId)) { @@ -392,5 +444,10 @@ function adminHandlePostAction(string $action, bool $isAjax, string $projectRoot } } - return ['message' => $message, 'errors' => $errors]; + return [ + 'message' => $message, + 'errors' => $errors, + 'deploy_status' => $deployStatus, + 'deploy_output' => $deployOutput, + ]; } diff --git a/lib/db.php b/lib/db.php index 82cd3c5..128973e 100644 --- a/lib/db.php +++ b/lib/db.php @@ -22,6 +22,26 @@ function appConfig(): array return $cfg; } +function appSecrets(): array +{ + static $secrets = null; + if ($secrets !== null) { + return $secrets; + } + + $path = __DIR__ . '/../secrets.php'; + if (!is_file($path)) { + throw new RuntimeException('secrets.php not found. Copy secrets.php.example'); + } + + $secrets = require $path; + if (!is_array($secrets)) { + throw new RuntimeException('Invalid secrets.php format'); + } + + return $secrets; +} + function db(): PDO { static $pdo = null; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 398d095..e0ea8cf 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,9 +7,11 @@ set -euo pipefail # Optional env: # APP_DIR=/home/USER/www/photo-gallery # BRANCH=main +# PHP_BIN=php APP_DIR="${APP_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" BRANCH="${BRANCH:-main}" +PHP_BIN="${PHP_BIN:-php}" cd "$APP_DIR" @@ -42,6 +44,9 @@ fi git fetch --all --prune git reset --hard "origin/$BRANCH" +# Run DB migrations required by current code +"$PHP_BIN" scripts/migrate.php + # Make sure runtime files exist [ -f data/last_indexed.txt ] || echo "0" > data/last_indexed.txt diff --git a/secrets.php.example b/secrets.php.example new file mode 100644 index 0000000..229a157 --- /dev/null +++ b/secrets.php.example @@ -0,0 +1,10 @@ + 'CHANGE_ME_TO_LONG_RANDOM_TOKEN', + 'basic_auth_user' => '', + 'basic_auth_pass' => '', + 'allowed_admin_ips' => [ + // '1.2.3.4', + ], +];