Návrh doménové služby: Rozbor třídy AnatomyInspectorService
Jak navrhnout robustní servisní třídu bez stavu (stateless service), chránit souborový systém před zranitelností Path Traversal a vracet silně typovaná neměnná DTO.
Návrh doménové služby v praxi
Servisní vrstva (Service Layer) představuje srdce doménové logiky aplikace. Dobře navržená servisní třída splňuje tyto vlastnosti:
- Je bezstavová (Stateless): Nedrží vnitřní stav mezi jednotlivými voláními. Výsledek metody závisí výhradně na předaných argumentech.
- Je silně typovaná: Všechny parametry i návratové hodnoty mají striktní PHP 8.4 typy.
- Vrací neměnná DTO: Místo surových asociativních polí (
array) vrací strukturované objektyfinal readonly class. - Je zabezpečená proti zneužití: Důsledně validuje hraniční podmínky a brání bezpečnostním rizikům.
Případová studie: AnatomyInspectorService
V naší aplikaci slouží služba AnatomyInspectorService k bezpečnému zkoumání zdrojových souborů projektu. Umožňuje studentům prohlížet reálný kód přímo v prohlížeči.
Práce se souborovým systémem na základě uživatelského vstupu však skrývá obrovské bezpečnostní riziko: Path Traversal útok (např. předání ../../../../etc/passwd nebo .env s produkčními hesly).
Třívrstvá bezpečnostní ochrana v isAllowedPath
Podívejme se, jak AnatomyInspectorService řeší bezpečnost na třech nezávislých úrovních:
private const array ALLOWED_PREFIXES = [
'app/',
'bootstrap/',
'config/',
'database/',
'docker/',
'public/index.php',
'resources/',
'routes/',
'tests/',
'composer.json',
'package.json',
'vite.config.js',
'compose.yaml',
'compose.prod.yaml',
'deploy.sh',
'AGENTS.md',
'.agents/memory/curriculum_registry.json',
];
private const array FORBIDDEN_FRAGMENTS = [
'.env',
'.git',
'vendor/',
'node_modules/',
'storage/',
'..',
];
- Černá listina fragmentů (Blacklist): Zakazuje nebezpečné řetězce jako
..(posun v adresářové struktuře),.env(tajné klíče),.gita adresáře třetích stran. - Bílá listina povolených cest (Whitelist): Soubor musí začínat jedním ze schválených prefixů projektu.
- Kanonizace cesty pomocí
realpath(): Funkcerealpath()vyřeší všechny symbolické odkazy i relativní cesty a převede je na absolutní kanonickou cestu. Služba následně ověří, zda tato cesta začíná kořenovým adresářem projektu:
$basePath = realpath(base_path());
$fullPath = base_path($cleanPath);
$realPath = realpath($fullPath);
if ($realPath === false || ! is_file($realPath)) {
return false;
}
$baseWithSlash = rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
return str_starts_with($realPath, $baseWithSlash);
Proč vracet DTO místo asociativního pole?
Místo vracení surového pole jako ['content' => '...', 'lines' => 120] vrací metoda inspectFile instanci AnatomyFileNodeData:
final readonly class AnatomyFileNodeData
{
public function __construct(
public string $path,
public string $category,
public string $description,
public bool $exists,
public int $lineCount,
public int $sizeBytes,
public string $content,
) {}
}
Výhody tohoto přístupu:
- Neměnnost: Klíčové slovo
readonlygarantuje, že vlastnosti nelze po vytvoření přepsat. - Typová jistota: PHPStan / Larastan i IDE okamžitě odhalí překlepy v názvech vlastností na úrovni statické analýzy.
- Autodokumentace: Každý vývojář přesně vidí, jaká data uzel souboru nese.
// app/Services/AnatomyInspectorService.php:88-121
public function inspectFile(string $relativePath): AnatomyFileNodeData
{
$cleanPath = ltrim(trim($relativePath), '/');
if (! $this->isAllowedPath($cleanPath)) {
return new AnatomyFileNodeData(
path: $cleanPath,
category: $this->determineCategory($cleanPath),
description: $this->determineDescription($cleanPath),
exists: false,
lineCount: 0,
sizeBytes: 0,
content: '// Přístup k souboru odepřen bezpečnostní politikou AnatomyInspectorService nebo soubor neexistuje.',
);
}
$fullPath = base_path($cleanPath);
$content = (string) file_get_contents($fullPath);
$lineCount = $content === '' ? 0 : substr_count($content, "\n") + (str_ends_with($content, "\n") ? 0 : 1);
$sizeBytes = strlen($content);
return new AnatomyFileNodeData(
path: $cleanPath,
category: $this->determineCategory($cleanPath),
description: $this->determineDescription($cleanPath),
exists: true,
lineCount: $lineCount,
sizeBytes: $sizeBytes,
content: $content,
);
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Jakým způsobem AnatomyInspectorService chrání aplikaci před útokem typu Path Traversal (Directory Traversal)?
Proč je pro přenos dat mezi vrstvami v PHP 8.4 výhodnější použít "final readonly class DTO" než asociativní pole?
Co vrátí metoda inspectFile() v případě, že je požadavek na soubor vyhodnocen jako nepovolený bezpečnostní politikou?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Services; |
| 6 | |
| 7 | use App\Data\AnatomyFileNodeData; |
| 8 | use App\Enums\LifecyclePhase; |
| 9 | |
| 10 | final class AnatomyInspectorService |
| 11 | { |
| 12 | private const array ALLOWED_PREFIXES = [ |
| 13 | 'app/', |
| 14 | 'bootstrap/', |
| 15 | 'config/', |
| 16 | 'database/', |
| 17 | 'docker/', |
| 18 | 'public/index.php', |
| 19 | 'resources/', |
| 20 | 'routes/', |
| 21 | 'tests/', |
| 22 | 'composer.json', |
| 23 | 'package.json', |
| 24 | 'vite.config.js', |
| 25 | 'compose.yaml', |
| 26 | 'compose.prod.yaml', |
| 27 | 'deploy.sh', |
| 28 | 'AGENTS.md', |
| 29 | '.agents/memory/curriculum_registry.json', |
| 30 | ]; |
| 31 | |
| 32 | private const array FORBIDDEN_FRAGMENTS = [ |
| 33 | '.env', |
| 34 | '.git', |
| 35 | 'vendor/', |
| 36 | 'node_modules/', |
| 37 | 'storage/', |
| 38 | '..', |
| 39 | ]; |
| 40 | |
| 41 | /** |
| 42 | * Ověří, zda je relativní cesta v souladu s bezpečnostní politikou |
| 43 | * a zda cílový soubor reálně existuje v kořenovém adresáři aplikace. |
| 44 | */ |
| 45 | public function isAllowedPath(string $relativePath): bool |
| 46 | { |
| 47 | $cleanPath = ltrim(trim($relativePath), '/'); |
| 48 | |
| 49 | if ($cleanPath === '') { |
| 50 | return false; |
| 51 | } |
| 52 | |
| 53 | foreach (self::FORBIDDEN_FRAGMENTS as $fragment) { |
| 54 | if (str_contains($cleanPath, $fragment)) { |
| 55 | return false; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | $hasAllowedPrefix = false; |
| 60 | foreach (self::ALLOWED_PREFIXES as $prefix) { |
| 61 | if (str_starts_with($cleanPath, $prefix)) { |
| 62 | $hasAllowedPrefix = true; |
| 63 | break; |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | if (! $hasAllowedPrefix) { |
| 68 | return false; |
| 69 | } |
| 70 | |
| 71 | $basePath = realpath(base_path()); |
| 72 | if ($basePath === false) { |
| 73 | return false; |
| 74 | } |
| 75 | |
| 76 | $fullPath = base_path($cleanPath); |
| 77 | $realPath = realpath($fullPath); |
| 78 | |
| 79 | if ($realPath === false || ! is_file($realPath)) { |
| 80 | return false; |
| 81 | } |
| 82 | |
| 83 | $baseWithSlash = rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; |
| 84 | |
| 85 | return str_starts_with($realPath, $baseWithSlash); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Prozkoumá soubor a vrátí jeho DTO uzel s obsahem a metadaty. |
| 90 | */ |
| 91 | public function inspectFile(string $relativePath): AnatomyFileNodeData |
| 92 | { |
| 93 | $cleanPath = ltrim(trim($relativePath), '/'); |
| 94 | |
| 95 | if (! $this->isAllowedPath($cleanPath)) { |
| 96 | return new AnatomyFileNodeData( |
| 97 | path: $cleanPath, |
| 98 | category: $this->determineCategory($cleanPath), |
| 99 | description: $this->determineDescription($cleanPath), |
| 100 | exists: false, |
| 101 | lineCount: 0, |
| 102 | sizeBytes: 0, |
| 103 | content: '// Přístup k souboru odepřen bezpečnostní politikou AnatomyInspectorService nebo soubor neexistuje.', |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | $fullPath = base_path($cleanPath); |
| 108 | $content = (string) file_get_contents($fullPath); |
| 109 | $lineCount = $content === '' ? 0 : substr_count($content, "\n") + (str_ends_with($content, "\n") ? 0 : 1); |
| 110 | $sizeBytes = strlen($content); |
| 111 | |
| 112 | return new AnatomyFileNodeData( |
| 113 | path: $cleanPath, |
| 114 | category: $this->determineCategory($cleanPath), |
| 115 | description: $this->determineDescription($cleanPath), |
| 116 | exists: true, |
| 117 | lineCount: $lineCount, |
| 118 | sizeBytes: $sizeBytes, |
| 119 | content: $content, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Prozkoumá kolekci relativních cest. |
| 125 | * |
| 126 | * @param array<int, string> $relativePaths |
| 127 | * @return array<int, AnatomyFileNodeData> |
| 128 | */ |
| 129 | public function inspectMany(array $relativePaths): array |
| 130 | { |
| 131 | $nodes = []; |
| 132 | foreach ($relativePaths as $path) { |
| 133 | $nodes[] = $this->inspectFile($path); |
| 134 | } |
| 135 | |
| 136 | return $nodes; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Vrátí kurátorský výběr nejdůležitějších exponátů živé anatomie projektu. |
| 141 | * |
| 142 | * @return array<int, AnatomyFileNodeData> |
| 143 | */ |
| 144 | public function getCuratedProjectCatalog(): array |
| 145 | { |
| 146 | $catalogPaths = [ |
| 147 | 'public/index.php', |
| 148 | 'bootstrap/app.php', |
| 149 | 'routes/web.php', |
| 150 | 'app/Http/Middleware/EnsureStudentProfile.php', |
| 151 | 'app/Http/Controllers/CourseController.php', |
| 152 | 'app/Http/Controllers/AnatomyController.php', |
| 153 | 'app/Services/AnatomyInspectorService.php', |
| 154 | 'app/Services/StudentProgressService.php', |
| 155 | 'app/Models/CourseModule.php', |
| 156 | 'app/Models/CourseLesson.php', |
| 157 | 'app/Models/QuizQuestion.php', |
| 158 | 'app/Models/StudentProfile.php', |
| 159 | 'app/Data/AnatomyFileNodeData.php', |
| 160 | 'app/Data/LessonContentData.php', |
| 161 | 'app/Enums/LifecyclePhase.php', |
| 162 | 'app/Enums/ModuleDifficulty.php', |
| 163 | 'app/Livewire/LessonQuiz.php', |
| 164 | 'app/Livewire/AnatomyExplorer.php', |
| 165 | 'app/Console/Commands/VerifyCurriculumCommand.php', |
| 166 | 'database/migrations/0001_01_01_000010_create_course_tables.php', |
| 167 | 'resources/views/layouts/app.blade.php', |
| 168 | 'docker/Dockerfile', |
| 169 | 'docker/Caddyfile', |
| 170 | 'docker/entrypoint.sh', |
| 171 | 'compose.yaml', |
| 172 | 'compose.prod.yaml', |
| 173 | 'deploy.sh', |
| 174 | 'composer.json', |
| 175 | 'AGENTS.md', |
| 176 | '.agents/memory/curriculum_registry.json', |
| 177 | ]; |
| 178 | |
| 179 | return $this->inspectMany($catalogPaths); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Určí architektonickou kategorii souboru. |
| 184 | */ |
| 185 | public function determineCategory(string $path): string |
| 186 | { |
| 187 | if (str_starts_with($path, 'docker/') || in_array($path, ['compose.yaml', 'compose.prod.yaml', 'deploy.sh'], true)) { |
| 188 | return 'DevOps & Docker'; |
| 189 | } |
| 190 | |
| 191 | if (str_starts_with($path, 'app/Http/Controllers/')) { |
| 192 | return 'Controller'; |
| 193 | } |
| 194 | |
| 195 | if (str_starts_with($path, 'app/Http/Middleware/')) { |
| 196 | return 'Middleware'; |
| 197 | } |
| 198 | |
| 199 | if (str_starts_with($path, 'app/Services/')) { |
| 200 | return 'Service Layer'; |
| 201 | } |
| 202 | |
| 203 | if (str_starts_with($path, 'app/Models/')) { |
| 204 | return 'Eloquent Model'; |
| 205 | } |
| 206 | |
| 207 | if (str_starts_with($path, 'app/Data/')) { |
| 208 | return 'Data Transfer Object (DTO)'; |
| 209 | } |
| 210 | |
| 211 | if (str_starts_with($path, 'app/Enums/')) { |
| 212 | return 'Enum'; |
| 213 | } |
| 214 | |
| 215 | if (str_starts_with($path, 'app/Livewire/')) { |
| 216 | return 'Livewire Component'; |
| 217 | } |
| 218 | |
| 219 | if (str_starts_with($path, 'app/Console/')) { |
| 220 | return 'Artisan Command'; |
| 221 | } |
| 222 | |
| 223 | if (str_starts_with($path, 'database/migrations/')) { |
| 224 | return 'Database Migration'; |
| 225 | } |
| 226 | |
| 227 | if (str_starts_with($path, 'database/seeders/')) { |
| 228 | return 'Database Seeder'; |
| 229 | } |
| 230 | |
| 231 | if (str_starts_with($path, 'resources/views/')) { |
| 232 | return 'Blade View'; |
| 233 | } |
| 234 | |
| 235 | if (str_starts_with($path, 'routes/')) { |
| 236 | return 'HTTP Routing'; |
| 237 | } |
| 238 | |
| 239 | if (str_starts_with($path, 'bootstrap/')) { |
| 240 | return 'Bootstrap & Pipeline'; |
| 241 | } |
| 242 | |
| 243 | if (str_starts_with($path, 'config/')) { |
| 244 | return 'Configuration'; |
| 245 | } |
| 246 | |
| 247 | if (str_starts_with($path, 'public/')) { |
| 248 | return 'HTTP Entry Point'; |
| 249 | } |
| 250 | |
| 251 | if (str_starts_with($path, 'tests/')) { |
| 252 | return 'Pest PHP Tests'; |
| 253 | } |
| 254 | |
| 255 | if (str_starts_with($path, 'AGENTS.md') || str_starts_with($path, '.agents/')) { |
| 256 | return 'Agentic Architecture & SSOT'; |
| 257 | } |
| 258 | |
| 259 | if (in_array($path, ['composer.json', 'package.json', 'vite.config.js'], true)) { |
| 260 | return 'Project Manifest & Config'; |
| 261 | } |
| 262 | |
| 263 | return 'Source Code'; |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Poskytne český popis architektonické role souboru v naší aplikaci. |
| 268 | */ |
| 269 | public function determineDescription(string $path): string |
| 270 | { |
| 271 | return match ($path) { |
| 272 | 'public/index.php' => 'Vstupní bod HTTP požadavku pro FrankenPHP webserver; inicializuje Composer autoloader a načítá bootstrap aplikace.', |
| 273 | 'bootstrap/app.php' => 'Nové jádro Laravel 12 pro registraci webových i konzolových rout, middleware pipeline a zachytávání výjimek bez Http Kernelu.', |
| 274 | 'routes/web.php' => 'Definice webových tras, Route Model Binding pro moduly a lekce, mapování na Controllery a API status endpoint.', |
| 275 | 'app/Http/Middleware/EnsureStudentProfile.php' => 'Middleware zajišťující anonymní UUID profil studenta v cookie s trvalým sledováním pokroku kurzem.', |
| 276 | 'app/Http/Controllers/CourseController.php' => 'Resource controller obsluhující přehled modulů, detail lekce, markdown render a přepínání profilu studenta.', |
| 277 | 'app/Http/Controllers/AnatomyController.php' => 'Controller interaktivní anatomie aplikace nabízející kurátorský katalog klíčových komponent a živý náhled kódu.', |
| 278 | 'app/Services/AnatomyInspectorService.php' => 'Bezpečná introspekční služba aplikace čtoucí reálný zdrojový kód s ochranou proti path traversal a whitelistem cest.', |
| 279 | 'app/Services/StudentProgressService.php' => 'Doménová služba pro správu anonymních profilů studentů, postupu lekcemi, výsledků kvízů a přenosových kódů.', |
| 280 | 'app/Models/CourseModule.php' => 'Eloquent model tématického modulu kurzu s vazbou hasMany na lekce a přetypováním na enum ModuleDifficulty.', |
| 281 | 'app/Models/CourseLesson.php' => 'Eloquent model lekce kurzu s obsahem v Markdownu, přetypováním JSON polí a enumem LifecyclePhase.', |
| 282 | 'app/Models/QuizQuestion.php' => 'Eloquent model otázky kvízu s JSON možnostmi, správnou volbou a vysvětlením pro interaktivní prověření znalostí.', |
| 283 | 'app/Models/StudentProfile.php' => 'Model anonymního studenta s UUID tokenem a přenosovým kódem pro synchronizaci postupu napříč zařízeními.', |
| 284 | 'app/Data/AnatomyFileNodeData.php' => 'Neměnné PHP 8.4 readonly DTO reprezentující analyzovaný soubor anatomie aplikace (řádky, bajty, obsah, popis).', |
| 285 | 'app/Data/LessonContentData.php' => 'Readonly DTO s kompletními daty lekce pro bezpečné předávání mezi modely, controllery a šablonami.', |
| 286 | 'app/Enums/LifecyclePhase.php' => 'PHP 8.4 string enum definující 11 kroků životního cyklu požadavku v Laravelu 12 s pořadím a popisky.', |
| 287 | 'app/Enums/ModuleDifficulty.php' => 'Enum určující obtížnost modulu (Začátečník, Pokročilý, Architekt) s Tailwind CSS badge třídami.', |
| 288 | 'app/Livewire/LessonQuiz.php' => 'Reaktivní Livewire 3 komponenta pro interaktivní testování znalostí s okamžitým vyhodnocením a záznamem skóre.', |
| 289 | 'app/Livewire/AnatomyExplorer.php' => 'Interaktivní průzkumník anatomických souborů s filtrováním fází a živým čtením přes AnatomyInspectorService.', |
| 290 | 'app/Console/Commands/VerifyCurriculumCommand.php' => 'Artisan příkaz pro zero-hallucination verifikaci registry osnovy proti fyzickým souborům a databázi.', |
| 291 | 'database/migrations/0001_01_01_000010_create_course_tables.php' => 'Migrační schéma pro moduly, lekce, kvízové otázky a anonymní profily studentů v PostgreSQL 18.', |
| 292 | 'resources/views/layouts/app.blade.php' => 'Hlavní Blade layout aplikace s tmavým tématem, navigací, synchronizací profilu a Alpine.js reaktivitou.', |
| 293 | 'docker/Dockerfile' => 'Multi-stage Dockerfile založený na dunglas/frankenphp:1-php8.4 s dev a prod cíli a instalací pgsql/opcache rozšíření.', |
| 294 | 'docker/Caddyfile' => 'Konfigurace Caddy webserveru a FrankenPHP worker módu pro bleskovou obsluhu požadavků bez opakovaného bootu.', |
| 295 | 'docker/entrypoint.sh' => 'Startovací skript kontejneru zajišťující práva storage, env soubory a spuštění Caddy/Octane.', |
| 296 | 'compose.yaml' => 'Docker Compose konfigurace pro lokální vývoj s FrankenPHP a PostgreSQL 18 alpine.', |
| 297 | 'compose.prod.yaml' => 'Produkční Docker Compose pro nasazení na Hetzner serveru s připojením na sdílenou síť db a caddy.', |
| 298 | 'deploy.sh' => 'Produkční nasazovací skript pro Hetzner server s migracemi, cacheováním a zero-downtime reloadem.', |
| 299 | 'composer.json' => 'Projektový manifest Composeru definující PHP 8.4, Laravel 12 závislosti a PSR-4 autoloading.', |
| 300 | 'AGENTS.md' => 'Závazná ústava projektu pro AI agenty s pravidly nulové halucinace a bezpečnosti Hetzneru.', |
| 301 | '.agents/memory/curriculum_registry.json' => 'Jediný zdroj pravdy (SSOT) pro osnovu 10 modulů a všech lekcí celého kurzu.', |
| 302 | default => 'Soubor aplikační architektury v modulu ' . $this->determineCategory($path) . '.', |
| 303 | }; |
| 304 | } |
| 305 | } |
| 306 | |