init
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
COMPOSE_PROJECT_NAME=webl
|
||||
APP_PORT=8090
|
||||
DB_HOST_PORT=5432
|
||||
POSTGRES_DB=webl
|
||||
POSTGRES_USER=webl
|
||||
POSTGRES_PASSWORD=webl_dev_password
|
||||
UID=1000
|
||||
GID=1000
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
app/vendor/
|
||||
app/storage/logs/
|
||||
.DS_Store
|
||||
*.bak
|
||||
@@ -0,0 +1,75 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
ifneq (,$(wildcard .env))
|
||||
include .env
|
||||
export
|
||||
endif
|
||||
|
||||
.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback sh psql
|
||||
|
||||
help:
|
||||
@echo "Доступные команды:"
|
||||
@echo " make init - создать .env из .env.example и подставить UID/GID текущего пользователя"
|
||||
@echo " make build - собрать Docker-образы (docker compose build)"
|
||||
@echo " make up - запустить контейнеры в фоне (docker compose up -d)"
|
||||
@echo " make down - остановить контейнеры (docker compose down)"
|
||||
@echo " make ps - показать состояние контейнеров (docker compose ps)"
|
||||
@echo " make logs - показать логи контейнеров в реальном времени"
|
||||
@echo " make clean - остановить контейнеры и УДАЛИТЬ данные PostgreSQL (docker compose down -v)"
|
||||
@echo " make setup - быстрый старт: init + build + up + composer + migrate"
|
||||
@echo " make composer - установить PHP-зависимости (composer install в контейнере)"
|
||||
@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 sh - открыть shell в контейнере приложения"
|
||||
@echo " make psql - подключиться к PostgreSQL (psql)"
|
||||
|
||||
init:
|
||||
@if [ -f .env ]; then \
|
||||
echo ".env уже существует"; \
|
||||
else \
|
||||
cp .env.example .env; \
|
||||
echo ".env создан из .env.example"; \
|
||||
fi
|
||||
@sed -i.bak -e "s/^UID=.*/UID=$$(id -u)/" -e "s/^GID=.*/GID=$$(id -g)/" .env && rm -f .env.bak
|
||||
@echo "UID/GID обновлены: UID=$$(id -u) GID=$$(id -g)"
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
clean:
|
||||
@echo "Удаление контейнеров и БД (volume pgdata)"
|
||||
docker compose down -v
|
||||
|
||||
setup: init build up composer migrate
|
||||
@echo "Готово: http://localhost:$${APP_PORT:-8080}"
|
||||
|
||||
composer:
|
||||
docker compose exec -u www-data app composer install
|
||||
|
||||
migrate:
|
||||
docker compose exec app php bin/migrate up
|
||||
|
||||
migrate-status:
|
||||
docker compose exec app php bin/migrate status
|
||||
|
||||
migrate-rollback:
|
||||
docker compose exec app php bin/migrate down
|
||||
|
||||
sh:
|
||||
docker compose exec app sh
|
||||
|
||||
psql:
|
||||
docker compose exec db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB)
|
||||
@@ -0,0 +1,73 @@
|
||||
# WebLab
|
||||
|
||||
Дз по лекциям
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```sh
|
||||
make setup
|
||||
```
|
||||
|
||||
Или:
|
||||
|
||||
```bash
|
||||
make init # создать .env (+UID/GID)
|
||||
make build # сборка образа
|
||||
make up # запустить контейнеры
|
||||
make composer # установить зависимости
|
||||
make migrate # применить миграции
|
||||
```
|
||||
|
||||
Приложение: <http://localhost:8090>
|
||||
|
||||
Порт задается `APP_PORT` в `.env` (по умолчанию 8090)
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Описание |
|
||||
|-------------------------|--------------------------------------------------------------|
|
||||
| `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 |
|
||||
|
||||
## Логи
|
||||
|
||||
Каждый HTTP-запрос логируется с `method`, `uri`, `status`, `body`.
|
||||
|
||||
- Консоль: `docker compose logs app`
|
||||
- Файлы: `app/storage/logs/app-*.log` (ротация 7 дней)
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
├── compose.yaml # сервисы db и app
|
||||
├── .env.example
|
||||
├── Makefile
|
||||
├── docker/php/ # Dockerfile и php.ini
|
||||
└── app/ # PHP-код (монтируется в контейнер)
|
||||
├── public/ # index.php, router.php
|
||||
├── src/ # исходники
|
||||
├── bin/ # мигратор
|
||||
├── database/ # миграции
|
||||
└── storage/logs/ # логи
|
||||
```
|
||||
|
||||
## Технологии
|
||||
|
||||
- PHP 8.5 (`php:8.5-cli`, встроенный сервер)
|
||||
- Eloquent — illuminate/database 12 (standalone)
|
||||
- Monolog 3
|
||||
- PostgreSQL 18 (`postgres:18-alpine`)
|
||||
- Docker Compose v2
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||
use Illuminate\Database\Migrations\DatabaseMigrationRepository;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$command = $argv[1] ?? 'up';
|
||||
|
||||
if (!in_array($command, ['up', 'down', 'status', 'refresh'], true)) {
|
||||
fwrite(STDERR, "Использование: migrate [up|down|status|refresh]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Capsule $capsule */
|
||||
$capsule = \App\Database::boot();
|
||||
|
||||
$container = $capsule->getContainer();
|
||||
$manager = $capsule->getDatabaseManager();
|
||||
|
||||
Facade::setFacadeApplication($container);
|
||||
|
||||
$container->instance('db', $manager);
|
||||
|
||||
$container->bind('db.schema', fn ($app) => $app['db']->connection()->getSchemaBuilder());
|
||||
|
||||
$repository = new DatabaseMigrationRepository($manager, 'migrations');
|
||||
|
||||
if (!$repository->repositoryExists()) {
|
||||
$repository->createRepository();
|
||||
}
|
||||
|
||||
$migrator = new Migrator($repository, $manager, new Filesystem());
|
||||
|
||||
$paths = [__DIR__ . '/../database/migrations'];
|
||||
|
||||
switch ($command) {
|
||||
case 'up':
|
||||
$migrator->run($paths);
|
||||
echo "Миграции применены.\n";
|
||||
break;
|
||||
case 'down':
|
||||
$migrator->rollback($paths);
|
||||
echo "Миграции откатены.\n";
|
||||
break;
|
||||
case 'status':
|
||||
$ran = $repository->getRan();
|
||||
$batches = $repository->getMigrationBatches();
|
||||
$files = $migrator->getMigrationFiles($paths);
|
||||
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
$name = $migrator->getMigrationName($file);
|
||||
if (in_array($name, $ran, true)) {
|
||||
$batch = $batches[$name] ?? '?';
|
||||
$rows[] = [$name, sprintf('[%s] Ran', $batch)];
|
||||
} else {
|
||||
$rows[] = [$name, 'Pending'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
echo "Миграции не найдены.\n";
|
||||
break;
|
||||
}
|
||||
|
||||
$nameWidth = max(mb_strlen('Migration name'), ...array_map(static fn (array $r): int => mb_strlen($r[0]), $rows));
|
||||
$statusWidth = max(mb_strlen('Batch/Status'), ...array_map(static fn (array $r): int => mb_strlen($r[1]), $rows));
|
||||
$line = '+' . str_repeat('-', $nameWidth + 2) . '+' . str_repeat('-', $statusWidth + 2) . '+';
|
||||
|
||||
echo $line, "\n";
|
||||
echo '| ' . str_pad('Migration name', $nameWidth) . ' | ' . str_pad('Batch/Status', $statusWidth) . " |\n";
|
||||
echo $line, "\n";
|
||||
foreach ($rows as $row) {
|
||||
echo '| ' . str_pad($row[0], $nameWidth) . ' | ' . str_pad($row[1], $statusWidth) . " |\n";
|
||||
}
|
||||
echo $line, "\n";
|
||||
break;
|
||||
case 'refresh':
|
||||
$migrator->rollback($paths);
|
||||
$migrator->run($paths);
|
||||
echo "Миграции обновлены (rollback + run).\n";
|
||||
break;
|
||||
}
|
||||
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, 'Ошибка миграции: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "agzamus/webl",
|
||||
"description": "Дз по лекции",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.5",
|
||||
"ext-pdo_pgsql": "*",
|
||||
"illuminate/database": "^12.0",
|
||||
"illuminate/events": "^12.0",
|
||||
"illuminate/filesystem": "^12.0",
|
||||
"monolog/monolog": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1721
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('notes', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('title');
|
||||
$table->text('content');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('notes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$logger = \App\Logger::get();
|
||||
\App\Http\RequestLog::register($logger);
|
||||
|
||||
try {
|
||||
\App\Database::boot();
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error('database.boot_failed', ['error' => $e->getMessage()]);
|
||||
http_response_code(500);
|
||||
exit('БД недоступна. Увы');
|
||||
}
|
||||
|
||||
// Шорткатик экранирования
|
||||
function h(string $v): string {
|
||||
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// POST
|
||||
$validationError = null;
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$title = trim((string) ($_POST['title'] ?? ''));
|
||||
$content = trim((string) ($_POST['content'] ?? ''));
|
||||
|
||||
if ($title === '' || $content === '') {
|
||||
http_response_code(422);
|
||||
$validationError = 'Заполните все поля';
|
||||
} else {
|
||||
try {
|
||||
\App\Models\Note::create(['title' => $title, 'content' => $content]);
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error('note.create_failed', ['error' => $e->getMessage()]);
|
||||
http_response_code(302);
|
||||
header('Location: /?err=1');
|
||||
exit;
|
||||
}
|
||||
http_response_code(302);
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ //
|
||||
|
||||
$notes = \App\Models\Note::query()->orderByDesc('id')->get();
|
||||
|
||||
$pgError = null;
|
||||
$pgVersion = null;
|
||||
try {
|
||||
$row = \App\Database::connection()->selectOne('SELECT version()');
|
||||
$pgVersion = (string) ($row->version ?? 'unknown');
|
||||
} catch (\Throwable $e) {
|
||||
$logger->error('database.version_failed', ['error' => $e->getMessage()]);
|
||||
$pgError = $e->getMessage();
|
||||
}
|
||||
|
||||
$showErrBanner = ($_GET['err'] ?? '') === '1';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#15141a">
|
||||
<title>webl — заметки</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #15141a;
|
||||
--bg-2: #121117;
|
||||
--surface: #1e1d24;
|
||||
--surface-2: #26252e;
|
||||
--surface-3: #302f3a;
|
||||
--line: #34333f;
|
||||
--line-strong: #4b4a59;
|
||||
--ink: #f4f4f6;
|
||||
--ink-2: #b8b7c2;
|
||||
--ink-3: #8f8e9b;
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #bdadff;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
--accent-tint: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--on-accent: #171224;
|
||||
--safe: #4fd6a6;
|
||||
--warn: #eec25f;
|
||||
--danger: #ff8a75;
|
||||
--float: 0 16px 40px -16px rgb(0 0 0 / .5), 0 2px 6px rgb(0 0 0 / .25);
|
||||
--ease: cubic-bezier(.22, 1, .36, 1);
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
font: 16px/1.6 var(--sans);
|
||||
color: var(--ink);
|
||||
background: radial-gradient(760px 380px at 50% -6%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 70%), var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 32px, 880px);
|
||||
margin-inline: auto;
|
||||
padding-block: clamp(36px, 7vh, 56px) clamp(48px, 9vh, 84px);
|
||||
padding-top: 20px
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
font: 600 11px/1.4 var(--mono);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.eyebrow--accent { color: var(--accent); }
|
||||
|
||||
.masthead h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.9rem, 5vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.masthead .dash { color: var(--accent); font-weight: 500; }
|
||||
|
||||
main { display: grid; gap: 18px; margin-top: 1em; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--float);
|
||||
padding: clamp(20px, 4vw, 28px);
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-head h2 { margin: 0; font-size: 1.3rem; font-weight: 650; line-height: 1.25; letter-spacing: -.01em; }
|
||||
.count {
|
||||
flex: none;
|
||||
font: 500 11px/1 var(--mono);
|
||||
letter-spacing: .06em;
|
||||
color: var(--ink-2);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
.alerts { display: grid; gap: 12px; }
|
||||
.alert {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 14px;
|
||||
padding: 13px 18px;
|
||||
border: 1px solid;
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
font-size: 15px;
|
||||
}
|
||||
.alert-tag { font: 600 10.5px/1.6 var(--mono); letter-spacing: .14em; text-transform: uppercase; }
|
||||
.alert--danger { border-color: color-mix(in srgb, var(--danger) 38%, transparent); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); }
|
||||
.alert--danger .alert-tag { color: var(--danger); }
|
||||
.alert--warn { border-color: color-mix(in srgb, var(--warn) 38%, transparent); background: color-mix(in srgb, var(--warn) 8%, var(--surface)); }
|
||||
.alert--warn .alert-tag { color: var(--warn); }
|
||||
|
||||
form { display: grid; gap: 16px; }
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font: 600 11px/1 var(--mono);
|
||||
letter-spacing: .09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
input[type="text"], textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
transition: border-color .18s var(--ease), box-shadow .18s var(--ease);
|
||||
}
|
||||
textarea { min-height: 150px; resize: vertical; line-height: 1.6; }
|
||||
input[type="text"]::placeholder, textarea::placeholder { color: var(--ink-3); }
|
||||
input[type="text"]:hover, textarea:hover { border-color: var(--line-strong); }
|
||||
input[type="text"]:focus-visible, textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.btn {
|
||||
justify-self: start;
|
||||
font: 650 15px/1 var(--sans);
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 22px;
|
||||
cursor: pointer;
|
||||
transition: background-color .18s var(--ease), transform .18s var(--ease);
|
||||
}
|
||||
.btn:hover { background: var(--accent-hover); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.notes { display: flex; flex-direction: column; }
|
||||
.note { padding: 18px 0; border-top: 1px solid var(--line); }
|
||||
.note:first-child { padding-top: 0; border-top: 0; }
|
||||
.note:last-child { padding-bottom: 0; }
|
||||
.note-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
font: 500 11.5px/1 var(--mono);
|
||||
letter-spacing: .05em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.note-id {
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.note-title { margin: 11px 0 4px; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||
.note-body { margin: 0; color: var(--ink-2); font-size: 15px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--line-strong);
|
||||
background: var(--bg-2);
|
||||
border-radius: 14px;
|
||||
padding: clamp(36px, 7vw, 56px) 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty .eyebrow { color: var(--accent); margin-bottom: 12px; }
|
||||
.empty-title { margin: 0; font-size: 1.0625rem; font-weight: 650; }
|
||||
.empty-sub { margin: 8px 0 0; font-size: 14.5px; color: var(--ink-3); }
|
||||
|
||||
.env { margin: 0; }
|
||||
.env-row { display: flex; flex-wrap: wrap; gap: 4px 24px; align-items: baseline; padding: 12px 0; border-top: 1px solid var(--line); }
|
||||
.env-row:first-child { padding-top: 0; border-top: 0; }
|
||||
.env-row:last-child { padding-bottom: 0; }
|
||||
.env-row dt { flex: 0 0 150px; font: 600 11px/1.5 var(--mono); letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.env-row dd { flex: 1 1 240px; margin: 0; font: 400 13.5px/1.5 var(--mono); overflow-wrap: anywhere; }
|
||||
.env-row dd.is-error { color: var(--danger); }
|
||||
|
||||
.env-card { margin-top: 18px; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.masthead { animation: rise .45s var(--ease) both; }
|
||||
main { animation: rise .45s var(--ease) .07s both; }
|
||||
.env-card { animation: rise .45s var(--ease) .14s both; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.card-head { align-items: center; }
|
||||
.env-row dt { flex-basis: 100%; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="masthead">
|
||||
<div class="eyebrow eyebrow--accent">WebLab <span class="dash">·</span> заметки</div>
|
||||
<!--<h1>WebLab <span class="dash">·</span> заметки</h1>-->
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php if ($showErrBanner || $validationError !== null): ?>
|
||||
<div class="alerts">
|
||||
<?php if ($showErrBanner): ?>
|
||||
<div class="alert alert--danger" role="alert">
|
||||
<span class="alert-tag">ошибка</span>Не удалось сохранить заметку
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($validationError !== null): ?>
|
||||
<div class="alert alert--warn" role="alert">
|
||||
<span class="alert-tag">проверка</span>
|
||||
<?= h($validationError) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="card" aria-labelledby="compose-title">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 id="compose-title">Новая заметка</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/">
|
||||
<div class="field">
|
||||
<label for="title">Название</label>
|
||||
<input type="text" id="title" name="title" required maxlength="255" placeholder="Домашнее задание">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="content">Текст</label>
|
||||
<textarea id="content" name="content" required placeholder="Реализовать статический конфиг с возможность добавлять зашифрованные значения"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Добавить</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="notes-title">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 id="notes-title">Заметки</h2>
|
||||
</div>
|
||||
<span class="count" aria-hidden="true"><?= h((string) $notes->count()) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if ($notes->isEmpty()): ?>
|
||||
<div class="empty">
|
||||
<p class="eyebrow">Заметок нет</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="notes">
|
||||
<?php foreach ($notes as $note): ?>
|
||||
<article class="note">
|
||||
<div class="note-meta">
|
||||
<span class="note-id">№ <?= h((string) $note->id) ?></span>
|
||||
<span><?= h((string) $note->created_at) ?></span>
|
||||
</div>
|
||||
<h3 class="note-title"><?= h((string) $note->title) ?></h3>
|
||||
<p class="note-body"><?= h((string) $note->content) ?></p>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
if (is_string($path) && $path !== '/' && is_file(__DIR__ . $path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
require __DIR__ . '/index.php';
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||
|
||||
final class Database {
|
||||
private static ?Capsule $capsule = null;
|
||||
|
||||
public static function boot(): Capsule {
|
||||
if (self::$capsule instanceof Capsule) {
|
||||
return self::$capsule;
|
||||
}
|
||||
|
||||
$capsule = new Capsule();
|
||||
|
||||
$capsule->addConnection([
|
||||
'driver' => 'pgsql',
|
||||
'host' => getenv('DB_HOST') ?: 'db',
|
||||
'port' => getenv('DB_PORT') ?: '5432',
|
||||
'database' => getenv('DB_NAME') ?: 'webl',
|
||||
'username' => getenv('DB_USER') ?: 'webl',
|
||||
'password' => getenv('DB_PASSWORD') ?: '',
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'schema' => 'public',
|
||||
'options' => [
|
||||
\PDO::ATTR_TIMEOUT => 3,
|
||||
],
|
||||
]);
|
||||
|
||||
$capsule->setAsGlobal();
|
||||
$capsule->bootEloquent();
|
||||
|
||||
self::$capsule = $capsule;
|
||||
|
||||
return self::$capsule;
|
||||
}
|
||||
|
||||
public static function connection(): \Illuminate\Database\ConnectionInterface {
|
||||
return self::boot()->getConnection();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
final class RequestLog {
|
||||
private static bool $registered = false;
|
||||
|
||||
public static function register(\Monolog\Logger $logger): void {
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
self::$registered = true;
|
||||
|
||||
$start = hrtime(true);
|
||||
|
||||
ob_start();
|
||||
|
||||
register_shutdown_function(static function () use ($logger, $start): void {
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
$status = http_response_code();
|
||||
if (!is_int($status)) {
|
||||
$status = 200;
|
||||
}
|
||||
|
||||
$durationMs = (hrtime(true) - $start) / 1_000_000;
|
||||
|
||||
try {
|
||||
$logger->info('http.request', [
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? '',
|
||||
'status' => $status,
|
||||
'duration_ms' => round($durationMs, 2),
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$err = error_get_last();
|
||||
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||||
$logger->error('http.fatal', [
|
||||
'error' => $err['message'],
|
||||
'file' => $err['file'],
|
||||
'line' => $err['line'],
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
|
||||
echo $body;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Logger {
|
||||
|
||||
private static ?\Monolog\Logger $logger = null;
|
||||
|
||||
public static function get(): \Monolog\Logger {
|
||||
if (self::$logger instanceof \Monolog\Logger) {
|
||||
return self::$logger;
|
||||
}
|
||||
|
||||
$logDir = __DIR__ . '/../storage/logs';
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0775, true);
|
||||
}
|
||||
|
||||
$logger = new \Monolog\Logger('webl');
|
||||
$logger->pushHandler(new \Monolog\Handler\StreamHandler('php://stdout', \Monolog\Level::Debug));
|
||||
$logger->pushHandler(new \Monolog\Handler\RotatingFileHandler($logDir . '/app.log', 7, \Monolog\Level::Debug));
|
||||
|
||||
self::$logger = $logger;
|
||||
|
||||
return self::$logger;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Note extends Model {
|
||||
protected $table = 'notes';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['title', 'content'];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "127.0.0.1:${DB_HOST_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./docker/php
|
||||
network: host # РКН блочит DNS, поэтому свой резолвер
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
command: php -S 0.0.0.0:8080 -t public public/router.php
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_USER: ${POSTGRES_USER}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./app:/var/www/html
|
||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1:8080/') === false ? 1 : 0);"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM php:8.5-cli
|
||||
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev unzip \
|
||||
&& docker-php-ext-install pdo_pgsql \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
RUN groupmod -o -g "${GID}" www-data \
|
||||
&& usermod -o -u "${UID}" -g "${GID}" www-data
|
||||
|
||||
WORKDIR /var/www/html
|
||||
@@ -0,0 +1,4 @@
|
||||
display_errors = On
|
||||
display_startup_errors = On
|
||||
error_reporting = E_ALL
|
||||
date.timezone = Europe/Moscow
|
||||
Reference in New Issue
Block a user