사용자 매뉴얼·번호알기·gov-portal 대시보드와 메뉴 동선·수불 리포트를 보강한다.
- 사용자 매뉴얼: league/commonmark 기반 bag/manual(로그인 전용), ManualRenderer + Config\Manual manifest, 콘텐츠 8종, E2E - 번호알기(봉투번호확인): bag/number-lookup, BagNumberLookup, E2E - gov-portal 대시보드 시안(기본/strip)·기본코드관리 화면 - 메뉴 관리: 등록·수정 후 메뉴 화면 유지, 수정 버튼 클릭 시 상단 스크롤 - 수불/분석 리포트(LOT 수불·반품/파기·수급계획·추이) 표시 보강 - .gitignore: docs/ → /docs/ 앵커링(최상위 개발문서만 제외, app/Docs는 추적) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
111
app/Libraries/ManualRenderer.php
Normal file
111
app/Libraries/ManualRenderer.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user