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=
Ветка: = h($deployBranch) ?>
+ + + + += h($deployOutput) ?>+ 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', + ], +];