Fáze požadavku: 7. Doménová vrstva & DTO
Modul 7 · Lekce 2

Návrh doménové služby: Rozbor třídy AnatomyInspectorService

💡 Shrnutí lekce

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:

  1. 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.
  2. Je silně typovaná: Všechny parametry i návratové hodnoty mají striktní PHP 8.4 typy.
  3. Vrací neměnná DTO: Místo surových asociativních polí (array) vrací strukturované objekty final readonly class.
  4. 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/',
    '..',
];
  1. Černá listina fragmentů (Blacklist): Zakazuje nebezpečné řetězce jako .. (posun v adresářové struktuře), .env (tajné klíče), .git a adresáře třetích stran.
  2. Bílá listina povolených cest (Whitelist): Soubor musí začínat jedním ze schválených prefixů projektu.
  3. Kanonizace cesty pomocí realpath(): Funkce realpath() 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 readonly garantuje, ž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.
💻 Klíčová konstrukce z praxe
// 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.

Otázka 1 z 3

Jakým způsobem AnatomyInspectorService chrání aplikaci před útokem typu Path Traversal (Directory Traversal)?

Otázka 2 z 3

Proč je pro přenos dat mezi vrstvami v PHP 8.4 výhodnější použít "final readonly class DTO" než asociativní pole?

Otázka 3 z 3

Co vrátí metoda inspectFile() v případě, že je požadavek na soubor vyhodnocen jako nepovolený bezpečnostní politikou?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: V souboru app/Services/AnatomyInspectorService.php prozkoumej metodu isAllowedPath() a inspectFile(). Všimni si kombinace konstant ALLOWED_PREFIXES, FORBIDDEN_FRAGMENTS a kontroly realpath(), která garantuje, že se kód nikdy nedostane mimo povolené hranice projektu. Výstupem je vždy DTO AnatomyFileNodeData.
cesta: app/Services/AnatomyInspectorService.php
305 řádků · 12.5 KB
Bezpečná introspekční služba aplikace čtoucí reálný zdrojový kód s ochranou proti path traversal a whitelistem cest.
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
cesta: app/Data/AnatomyFileNodeData.php
18 řádků · 0.4 KB
Neměnné PHP 8.4 readonly DTO reprezentující analyzovaný soubor anatomie aplikace (řádky, bajty, obsah, popis).
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Data;
6
7 final readonly class AnatomyFileNodeData
8 {
9 public function __construct(
10 public string $path,
11 public string $category,
12 public string $description,
13 public bool $exists,
14 public int $lineCount,
15 public int $sizeBytes,
16 public string $content,
17 ) {}
18 }
19
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →