config = $config ?? config(ManualConfig::class); $this->converter = new GithubFlavoredMarkdownConverter([ // 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다. 'html_input' => 'escape', 'allow_unsafe_links' => false, 'max_nesting_level' => 50, ]); } /** * 목차(slug → title/file). 배열 순서가 노출 순서. * * @return array */ public function pages(): array { return $this->config->pages; } /** 목차의 첫 번째 slug (기본 진입 페이지). */ public function firstSlug(): string { return (string) (array_key_first($this->config->pages) ?? ''); } /** * slug 메타 조회. 화이트리스트에 없으면 null. * * @return array{title: string, file: string}|null */ public function find(string $slug): ?array { return $this->config->pages[$slug] ?? null; } /** * slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null. */ public function render(string $slug): ?string { $page = $this->find($slug); if ($page === null) { return null; } $path = $this->resolvePath($page['file']); if ($path === null) { return null; } $markdown = @file_get_contents($path); if ($markdown === false) { return null; } try { return (string) $this->converter->convert($markdown); } catch (CommonMarkException) { return null; } } /** * 파일명을 디렉터리 경계 안으로 안전하게 해석한다. * manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다. */ private function resolvePath(string $file): ?string { $baseReal = realpath(rtrim($this->config->dir, '/\\')); if ($baseReal === false) { return null; } $candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file); if ($candidate === false || ! is_file($candidate)) { return null; } $prefix = $baseReal . DIRECTORY_SEPARATOR; if (! str_starts_with($candidate, $prefix)) { return null; } return $candidate; } }