2026-06-08 00:46:51 +09:00
|
|
|
<?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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 19:52:53 +09:00
|
|
|
/**
|
|
|
|
|
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
|
|
|
|
|
*/
|
|
|
|
|
public function plainText(string $slug): string
|
|
|
|
|
{
|
|
|
|
|
$html = $this->render($slug);
|
|
|
|
|
if ($html === null || $html === '') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
|
|
|
|
|
return trim((string) preg_replace('/\s+/u', ' ', $text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
|
|
|
|
|
*
|
|
|
|
|
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
|
|
|
|
|
*/
|
|
|
|
|
public function search(string $q): array
|
|
|
|
|
{
|
|
|
|
|
$q = trim($q);
|
|
|
|
|
if ($q === '' || mb_strlen($q) < 1) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
$needle = mb_strtolower($q);
|
|
|
|
|
$out = [];
|
|
|
|
|
foreach ($this->pages() as $slug => $page) {
|
|
|
|
|
$text = $this->plainText((string) $slug);
|
|
|
|
|
if ($text === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$hay = mb_strtolower($text);
|
|
|
|
|
$pos = mb_strpos($hay, $needle);
|
|
|
|
|
if ($pos === false) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$hits = mb_substr_count($hay, $needle);
|
|
|
|
|
$start = max(0, $pos - 30);
|
|
|
|
|
$snippet = mb_substr($text, $start, 100);
|
|
|
|
|
if ($start > 0) {
|
|
|
|
|
$snippet = '…' . $snippet;
|
|
|
|
|
}
|
|
|
|
|
$out[] = [
|
|
|
|
|
'slug' => (string) $slug,
|
|
|
|
|
'title' => (string) $page['title'],
|
|
|
|
|
'snippet' => trim($snippet),
|
|
|
|
|
'hits' => $hits,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
|
|
|
|
|
|
|
|
|
|
return $out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 00:46:51 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|