diff --git a/.env.example b/.env.example index e3372a1..468243d 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ POSTGRES_USER=webl POSTGRES_PASSWORD=webl_dev_password UID=1000 GID=1000 +APP_ENV=local +# для APP_KEY: make keygen +APP_KEY= diff --git a/Makefile b/Makefile index f9a7259..b27c9c1 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ include .env export endif -.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback sh psql +.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback keygen config-show sh psql help: @echo "Доступные команды:" @@ -21,6 +21,8 @@ help: @echo " make migrate - применить миграции базы данных (php bin/migrate up)" @echo " make migrate-status - показать статус миграций (php bin/migrate status)" @echo " make migrate-rollback - откатить последнюю миграцию (php bin/migrate down)" + @echo " make keygen - сгенерировать APP_KEY (php bin/config keygen)" + @echo " make config-show - показать итоговый конфиг (php bin/config show)" @echo " make sh - открыть shell в контейнере приложения" @echo " make psql - подключиться к PostgreSQL (psql)" @@ -68,6 +70,14 @@ migrate-status: migrate-rollback: docker compose exec app php bin/migrate down +keygen: + @KEY=$$(docker compose exec -T app php bin/config keygen | head -n1); \ + sed -i.bak -e "s|^APP_KEY=.*|APP_KEY=$$KEY|" .env && rm -f .env.bak; \ + echo "APP_KEY обновлён в .env. Перезапусти app: make up (старые enc:-значения нужно перешифровать)" + +config-show: + docker compose exec app php bin/config show + sh: docker compose exec app sh diff --git a/README.md b/README.md index aa159c8..baaa23d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ Дз по лекциям +## Возможности + +1. **Заметки** - создание и просмотр заметок через веб-интерфейс (`app/public/index.php`, модель `App\Models\Note`). +2. **Миграции БД** - версионирование схемы PostgreSQL через `php bin/migrate` и Eloquent. +3. **Логирование запросов** - каждый HTTP-запрос пишется с `method`, `uri`, `status`, `body`. +4. **Статический конфиг с шифрованными значениями** - файлы конфигурации по окружениям (`app/config/*.php`), шифрование значений через `App\Crypto\Encryptor` (openssl AES-256-GCM) и CLI-утилиты. + ## Быстрый старт ```sh @@ -24,23 +31,25 @@ make migrate # применить миграции ## Команды -| Команда | Описание | -|-------------------------|--------------------------------------------------------------| -| `make help` | Показать список всех команд | -| `make init` | Создать `.env` из `.env.example`, подставить UID/GID | -| `make build` | Собрать Docker-образы | -| `make up` | Запустить контейнеры в фоне | -| `make down` | Остановить контейнеры | -| `make ps` | Показать состояние контейнеров | -| `make logs` | Логи контейнеров в реальном времени | -| `make clean` | Остановить контейнеры и удалить данные PostgreSQL (`-v`) | -| `make setup` | Быстрый старт: init + build + up + composer + migrate | -| `make composer` | `composer install` внутри контейнера | -| `make migrate` | Применить миграции (`php bin/migrate up`) | -| `make migrate-status` | Статус миграций (`php bin/migrate status`) | -| `make migrate-rollback` | Откатить последнюю миграцию (`php bin/migrate down`) | -| `make sh` | Shell внутри контейнера приложения | -| `make psql` | Подключиться к PostgreSQL | +| Команда | Описание | +|-------------------------|-----------------------------------------------------------------------| +| `make help` | Показать список всех команд | +| `make init` | Создать `.env` из `.env.example`, подставить UID/GID | +| `make build` | Собрать Docker-образы | +| `make up` | Запустить контейнеры в фоне | +| `make down` | Остановить контейнеры | +| `make ps` | Показать состояние контейнеров | +| `make logs` | Логи контейнеров в реальном времени | +| `make clean` | Остановить контейнеры и удалить данные PostgreSQL (`-v`) | +| `make setup` | Быстрый старт: init + build + up + composer + migrate | +| `make composer` | `composer install` внутри контейнера | +| `make migrate` | Применить миграции (`php bin/migrate up`) | +| `make migrate-status` | Статус миграций (`php bin/migrate status`) | +| `make migrate-rollback` | Откатить последнюю миграцию (`php bin/migrate down`) | +| `make keygen` | Сгенерировать `APP_KEY` и записать в `.env` (`php bin/config keygen`) | +| `make config-show` | Показать конфиг текущего окружения (`php bin/config show`) | +| `make sh` | Shell внутри контейнера приложения | +| `make psql` | Подключиться к PostgreSQL | ## Логи @@ -49,6 +58,67 @@ make migrate # применить миграции - Консоль: `docker compose logs app` - Файлы: `app/storage/logs/app-*.log` (ротация 7 дней) +## Конфиг и шифрование + +Окружение и ключ шифрования читаются из переменных окружения (`.env` -> `compose.yaml`): + +| Переменная | Назначение | По умолчанию | +|------------|------------------------------------------------------------------|--------------| +| `APP_ENV` | Окружение: `local`, `dev` или `prod` | `local` | +| `APP_KEY` | base64-ключ длиной 32 байта для AES-256-GCM | - | + +Ключ: `.env` в `.gitignore`, а в `.env.example` лежит только плейсхолдер. Сгенерировать и записать ключ в `.env` можно командой `make keygen`. После смены ключа перезапустите приложение (`make up`) и перешифруйте `enc:`-значения - под новым ключом старые не расшифруются. + +Файлы конфигурации лежат в `app/config/*.php` (например, `app.php`, `database.php`). Каждый файл возвращает массив с секциями по окружениям; значения окружения накладываются поверх `default` (`array_replace_recursive`): + +```php + [ + // общие значения для всех окружений + ], + 'local' => [ + // переопределения для APP_ENV=local + ], + 'dev' => [], + 'prod' => [], +]; +``` + +Значения, начинающиеся с `enc:v1:`, расшифровываются автоматически при загрузке конфига. Конверт (`envelope`) имеет вид `enc:v1:` + `base64(iv | tag | ciphertext)`, шифр - `aes-256-gcm` (`App\Crypto\Encryptor`). В не-production окружениях битый шифротекст или отсутствующий ключ деградирует до `null` с предупреждением в лог; в `prod` в этом случае выбрасывается исключение. + +Консольные утилиты (`bin/config`): + +```sh +docker compose exec -T app php bin/config keygen # сгенерировать APP_KEY +docker compose exec -T app php bin/config encrypt "значение" +docker compose exec -T app php bin/config decrypt "enc:v1:..." +docker compose exec -T app php bin/config show # показать конфиг для APP_ENV + +make keygen +make config-show +``` + +Внутри контейнера также доступен CLI `openssl` (`/usr/bin/openssl`, версия 3.5.7) - для ручных операций + +## Схема БД + +| Таблица | Ключевые колонки | +|-----------------|-----------------------------------------------------------------------------------------------------------| +| `users` | `user_id` (PK), `email`, `password_hash`, `name`, `created_datetime`, `updated_datetime` | +| `boards` | `board_id` (PK), `title`, `owner_user_id` → `users.user_id`, `is_public`, `created_datetime`, `updated_datetime` | +| `board_columns` | `column_id` (PK), `board_id` → `boards.board_id`, `ordinal_position`, `title`, `color` | +| `cards` | `card_id` (PK), `board_id` → `boards.board_id`, `column_id` → `board_columns.column_id`, `sort_index`, `title`, `content`, `color`, `is_archived`, `created_datetime`, `updated_datetime` | + +| Индекс | Тип | Таблица / колонки | +|------------------------|-----------|------------------------------------------------------| +| `idx_email` | UNIQUE | `users (email)` | +| `idx_board_id` | обычный | `board_columns (board_id)` | +| `idx_active_board_id` | PARTIAL | `cards (board_id)` WHERE `is_archived = false` | + +Миграции лежат в `app/database/migrations`, применяются командой `make migrate` и откатываются `make migrate-rollback`. + ## Структура ``` @@ -59,7 +129,10 @@ make migrate # применить миграции └── app/ # PHP-код (монтируется в контейнер) ├── public/ # index.php, router.php ├── src/ # исходники - ├── bin/ # мигратор + │ ├── Config/ # App\Config\Config - загрузка и слияние конфигов + │ └── Crypto/ # App\Crypto\Encryptor - AES-256-GCM + ├── config/ # конфиги по окружениям (app.php, database.php, ...) + ├── bin/ # мигратор (migrate) и CLI конфига (config) ├── database/ # миграции └── storage/logs/ # логи ``` @@ -67,7 +140,7 @@ make migrate # применить миграции ## Технологии - PHP 8.5 (`php:8.5-cli`, встроенный сервер) -- Eloquent — illuminate/database 12 (standalone) +- Eloquent - illuminate/database 12 (standalone) - Monolog 3 - PostgreSQL 18 (`postgres:18-alpine`) - Docker Compose v2 diff --git a/app/bin/config b/app/bin/config new file mode 100755 index 0000000..cb5cae5 --- /dev/null +++ b/app/bin/config @@ -0,0 +1,80 @@ +#!/usr/bin/env php + $value ? 'true' : 'false', + $value === null => 'null', + is_array($value) => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]', + default => (string) $value, + }; +}; + +$command = $argv[1] ?? null; + +try { + switch ($command) { + case 'keygen': + echo Encryptor::generateKey(), "\n"; + echo "Помести ключ в APP_KEY (.env)\n"; + break; + + case 'encrypt': + if (!isset($argv[2])) { + throw new \RuntimeException('Укажи значение: config encrypt '); + } + echo Encryptor::fromEnv()->encrypt($argv[2]), "\n"; + break; + + case 'decrypt': + if (!isset($argv[2])) { + throw new \RuntimeException('Укажи значение: config decrypt '); + } + echo Encryptor::fromEnv()->decrypt($argv[2]), "\n"; + break; + + case 'show': + $config = Config::load(); + $filter = $argv[2] ?? null; + $values = $config->all(); + + if ($filter !== null) { + $values = array_filter( + $values, + static fn (string $key): bool => $key === $filter || str_starts_with($key, $filter . '.'), + ARRAY_FILTER_USE_KEY, + ); + } + + if ($values === []) { + fwrite(STDERR, "Значения не найдены\n"); + exit(1); + } + + $width = max(array_map('strlen', array_keys($values))); + foreach ($values as $key => $value) { + echo str_pad($key, $width), ' = ', $format($value), "\n"; + } + break; + + default: + fwrite(STDERR, "Использование: config [args]\n"); + fwrite(STDERR, " keygen - сгенерировать новый APP_KEY\n"); + fwrite(STDERR, " encrypt - зашифровать значение\n"); + fwrite(STDERR, " decrypt - расшифровать значение\n"); + fwrite(STDERR, " show [key] - показать конфиг текущего APP_ENV\n"); + exit(1); + } + + exit(0); +} catch (Throwable $e) { + fwrite(STDERR, 'Ошибка конфигурации: ' . $e->getMessage() . "\n"); + exit(1); +} diff --git a/app/config/app.php b/app/config/app.php new file mode 100644 index 0000000..2900ab8 --- /dev/null +++ b/app/config/app.php @@ -0,0 +1,18 @@ + [ + 'name' => 'webl', + 'debug' => false, + 'timezone' => 'Europe/Moscow', + ], + 'local' => [ + 'debug' => true, + ], + 'dev' => [ + 'debug' => true, + ], + 'prod' => [], +]; diff --git a/app/config/database.php b/app/config/database.php new file mode 100644 index 0000000..adc1ecf --- /dev/null +++ b/app/config/database.php @@ -0,0 +1,23 @@ + [ + 'driver' => 'pgsql', + 'host' => 'db', + 'port' => 5432, + 'database' => 'webl', + 'username' => 'webl', + ], + 'local' => [ + 'driver' => 'pgsql', + 'host' => 'db', + 'port' => 5432, + 'database' => 'webl', + 'username' => 'webl', + ], + 'prod' => [ + 'debug_sql' => false, + ], +]; diff --git a/app/src/Config/Config.php b/app/src/Config/Config.php new file mode 100644 index 0000000..07b92e8 --- /dev/null +++ b/app/src/Config/Config.php @@ -0,0 +1,124 @@ + $values) { + $sections[$section] = self::decryptValues($values, $section, $env, $encryptor); + } + + $data = []; + foreach ($sections as $section => $values) { + foreach (self::flatten($values, $section) as $key => $value) { + $data[$key] = $value; + } + } + + return self::$instance = new self($env, $sections, $data); + } + + public static function instance(): self { + return self::$instance ??= self::load(); + } + + public static function get(string $key, mixed $default = null): mixed { + $config = self::instance(); + + if (array_key_exists($key, $config->data)) { + return $config->data[$key]; + } + + $value = $config->sections; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } + + public function all(): array { + return $this->data; + } + + public function env(): string { + return $this->env; + } + + private static function decryptValues(array $values, string $prefix, string $env, ?Encryptor &$encryptor): array { + foreach ($values as $key => $value) { + $dotKey = $prefix . '.' . $key; + + if (is_array($value)) { + $values[$key] = self::decryptValues($value, $dotKey, $env, $encryptor); + continue; + } + + if (!is_string($value) || !str_starts_with($value, Encryptor::MARKER)) { + continue; + } + + try { + $encryptor ??= Encryptor::fromEnv(); + $values[$key] = $encryptor->decrypt($value); + } catch (\Throwable $e) { + if ($env === 'prod') { + throw $e; + } + + \App\Logger::get()->warning('config.decrypt_failed', [ + 'key' => $dotKey, + 'error' => $e->getMessage(), + ]); + $values[$key] = null; + } + } + + return $values; + } + + private static function flatten(array $values, string $prefix): array { + $flat = []; + foreach ($values as $key => $value) { + $dotKey = $prefix . '.' . $key; + if (is_array($value) && $value !== []) { + $flat += self::flatten($value, $dotKey); + continue; + } + $flat[$dotKey] = $value; + } + + return $flat; + } +} diff --git a/app/src/Crypto/Encryptor.php b/app/src/Crypto/Encryptor.php new file mode 100644 index 0000000..5c11721 --- /dev/null +++ b/app/src/Crypto/Encryptor.php @@ -0,0 +1,85 @@ +key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH); + if ($ciphertext === false) { + throw new \RuntimeException('Не удалось зашифровать'); + } + + return self::MARKER . self::VERSION . ':' . base64_encode($iv . $tag . $ciphertext); + } + + public function decrypt(string $payload): string { + if (str_starts_with($payload, self::MARKER)) { + $payload = substr($payload, strlen(self::MARKER)); + } + + if (!str_starts_with($payload, self::VERSION . ':')) { + throw new \RuntimeException('Неизвестная версия шифра'); + } + + $raw = base64_decode(substr($payload, strlen(self::VERSION) + 1), true); + if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH) { + throw new \RuntimeException('Битый шифр'); + } + + $iv = substr($raw, 0, self::IV_LENGTH); + $tag = substr($raw, self::IV_LENGTH, self::TAG_LENGTH); + $ciphertext = substr($raw, self::IV_LENGTH + self::TAG_LENGTH); + + $plaintext = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag); + if ($plaintext === false) { + throw new \RuntimeException('Не удалось расшифровать: тег не тот'); + } + + return $plaintext; + } + + public function isEncrypted(string $value): bool { + return str_starts_with($value, self::MARKER); + } + + public function decryptIfEncrypted(string $value): string { + return $this->isEncrypted($value) ? $this->decrypt($value) : $value; + } +} diff --git a/compose.yaml b/compose.yaml index 34978af..295120c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,8 @@ services: DB_NAME: ${POSTGRES_DB} DB_USER: ${POSTGRES_USER} DB_PASSWORD: ${POSTGRES_PASSWORD} + APP_ENV: ${APP_ENV:-local} + APP_KEY: ${APP_KEY:-} volumes: - ./app:/var/www/html - ./docker/php/php.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index bbf273a..1ec2189 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -13,3 +13,5 @@ RUN groupmod -o -g "${GID}" www-data \ && usermod -o -u "${UID}" -g "${GID}" www-data WORKDIR /var/www/html + +RUN php -r 'exit((int)!extension_loaded("openssl"));'