Files
jongryangje/app/Libraries/ManualRenderer.php

112 lines
3.1 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Libraries;
use Config\Manual as ManualConfig;
use League\CommonMark\Exception\CommonMarkException;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* 사용자 매뉴얼(설명서) 렌더러.
*
* - 목차(manifest) Config\Manual 에서 가져온다.
* - slug 파일 매핑은 화이트리스트(manifest)로만 결정한다(사용자 입력으로 파일명 조합 금지).
* - 마크다운은 GFM(표·코드블록) 변환하며, md raw HTML 이스케이프한다.
*/
class ManualRenderer
{
private ManualConfig $config;
private GithubFlavoredMarkdownConverter $converter;
public function __construct(?ManualConfig $config = null)
{
$this->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<string, array{title: string, file: string}>
*/
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;
}
}