From 1bb2ac32f94c38f90deb218fad94d6522dcce928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 23 Feb 2026 07:57:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[additional]=20PPTX=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일시스템 스캔 기반 PPTX 목록 조회/다운로드 - 카테고리별 필터, 파일명 검색 기능 - 경로 트래버설 방지 보안 검증 --- .../Controllers/Additional/PptxController.php | 181 ++++++++++++++++ .../views/additional/pptx/index.blade.php | 202 ++++++++++++++++++ routes/web.php | 6 + 3 files changed, 389 insertions(+) create mode 100644 app/Http/Controllers/Additional/PptxController.php create mode 100644 resources/views/additional/pptx/index.blade.php diff --git a/app/Http/Controllers/Additional/PptxController.php b/app/Http/Controllers/Additional/PptxController.php new file mode 100644 index 00000000..1f9e82a0 --- /dev/null +++ b/app/Http/Controllers/Additional/PptxController.php @@ -0,0 +1,181 @@ + PPTX 관리 컨트롤러 + * 파일시스템 스캔 기반으로 PPTX 파일 목록 조회 및 다운로드 + */ +class PptxController extends Controller +{ + private array $scanPaths = [ + '/var/www/docs/pptx-output' => ['label' => '산출물', 'source' => 'docs'], + '/var/www/docs/rules' => ['label' => '정책/규칙', 'source' => 'docs'], + '/var/www/docs/guides' => ['label' => '가이드', 'source' => 'docs'], + '/var/www/docs/projects' => ['label' => '프로젝트', 'source' => 'docs'], + '/var/www/docs/plans' => ['label' => '계획', 'source' => 'docs'], + '/var/www/mng/docs/pptx-output' => ['label' => '산출물', 'source' => 'mng'], + '/var/www/mng/docs' => ['label' => '교육/문서', 'source' => 'mng'], + '/var/www/mng/public/docs' => ['label' => '공개 문서', 'source' => 'mng'], + ]; + + /** + * PPTX 목록 페이지 + */ + public function index(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('additional.pptx.index')); + } + + $files = $this->scanPptxFiles(); + $categories = collect($files)->pluck('category')->unique()->sort()->values()->all(); + $totalSize = collect($files)->sum('size_bytes'); + + return view('additional.pptx.index', [ + 'files' => $files, + 'categories' => $categories, + 'totalCount' => count($files), + 'totalSize' => $this->formatFileSize($totalSize), + ]); + } + + /** + * PPTX 파일 다운로드 + */ + public function download(Request $request): BinaryFileResponse|Response + { + $filePath = $request->query('file'); + + if (! $filePath) { + abort(400, '파일 경로가 지정되지 않았습니다.'); + } + + // 경로 트래버설 방지 + if (str_contains($filePath, '..')) { + abort(403, '잘못된 파일 경로입니다.'); + } + + // .pptx 확장자만 허용 + if (strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) !== 'pptx') { + abort(403, '허용되지 않는 파일 형식입니다.'); + } + + // 허용된 base path에 속하는지 검증 + $allowed = false; + foreach (array_keys($this->scanPaths) as $basePath) { + if (str_starts_with($filePath, $basePath.'/')) { + $allowed = true; + break; + } + } + + if (! $allowed) { + abort(403, '허용되지 않는 경로입니다.'); + } + + if (! file_exists($filePath)) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + return response()->download($filePath, basename($filePath), [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ]); + } + + /** + * 모든 스캔 경로에서 PPTX 파일 수집 + */ + private function scanPptxFiles(): array + { + $files = []; + $seenPaths = []; + + foreach ($this->scanPaths as $basePath => $meta) { + if (! is_dir($basePath)) { + continue; + } + + $this->findPptxInDirectory($basePath, $basePath, $meta, $files, $seenPaths); + } + + // 수정일 내림차순 정렬 + usort($files, fn ($a, $b) => $b['modified_at'] <=> $a['modified_at']); + + return $files; + } + + /** + * 디렉토리에서 PPTX 파일 재귀 검색 + */ + private function findPptxInDirectory(string $dir, string $basePath, array $meta, array &$files, array &$seenPaths): void + { + $items = scandir($dir); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $fullPath = $dir.'/'.$item; + + if (is_dir($fullPath)) { + // mng/docs 경로에서 하위 디렉토리 중 다른 scanPath에 해당하는 것은 건너뜀 + if (isset($this->scanPaths[$fullPath])) { + continue; + } + $this->findPptxInDirectory($fullPath, $basePath, $meta, $files, $seenPaths); + + continue; + } + + if (strtolower(pathinfo($item, PATHINFO_EXTENSION)) !== 'pptx') { + continue; + } + + // 중복 방지 (realpath 기반) + $realPath = realpath($fullPath); + if ($realPath && isset($seenPaths[$realPath])) { + continue; + } + if ($realPath) { + $seenPaths[$realPath] = true; + } + + $relativePath = str_replace($basePath.'/', '', $fullPath); + $sizeBytes = filesize($fullPath); + $modifiedAt = filemtime($fullPath); + + $files[] = [ + 'name' => $item, + 'path' => $fullPath, + 'relative_path' => $relativePath, + 'category' => $meta['label'], + 'source' => $meta['source'], + 'source_dir' => str_replace('/var/www/', '', $basePath), + 'size_bytes' => $sizeBytes, + 'size' => $this->formatFileSize($sizeBytes), + 'modified_at' => $modifiedAt, + 'modified_date' => date('Y-m-d H:i', $modifiedAt), + ]; + } + } + + private function formatFileSize(int $bytes): string + { + if ($bytes >= 1048576) { + return number_format($bytes / 1048576, 1).' MB'; + } + + return number_format($bytes / 1024, 0).' KB'; + } +} diff --git a/resources/views/additional/pptx/index.blade.php b/resources/views/additional/pptx/index.blade.php new file mode 100644 index 00000000..b896eae2 --- /dev/null +++ b/resources/views/additional/pptx/index.blade.php @@ -0,0 +1,202 @@ +@extends('layouts.app') + +@section('title', 'PPTX 관리') + +@push('styles') + +@endpush + +@section('content') +
+ {{-- 헤더 --}} +
+

PPTX 관리

+

회사 프레젠테이션 자료를 한 곳에서 관리합니다

+
+ + {{-- 통계 바 --}} +
+
+
+ +
+
+
{{ $totalCount }}
+
전체 파일
+
+
+
+
+ +
+
+
{{ $totalSize }}
+
총 크기
+
+
+
+ + {{-- 검색 + 필터 --}} +
+ +
+ + @foreach ($categories as $cat) + + @endforeach +
+
+ + {{-- 파일 목록 --}} +
+ @forelse ($files as $file) +
+
+ +
+
+
{{ $file['name'] }}
+
+ {{ $file['category'] }} + {{ $file['source_dir'] }} + | + {{ $file['size'] }} + | + {{ $file['modified_date'] }} +
+
+ + + 다운로드 + +
+ @empty +
+ +

PPTX 파일이 없습니다

+
+ @endforelse +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/routes/web.php b/routes/web.php index bc1cc790..cb5c5657 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Additional\KioskController; use App\Http\Controllers\Additional\NotionSearchController; +use App\Http\Controllers\Additional\PptxController; use App\Http\Controllers\Additional\RagSearchController; use App\Http\Controllers\Api\BusinessCardOcrController; use App\Http\Controllers\ApiLogController; @@ -720,6 +721,11 @@ Route::get('/', [RagSearchController::class, 'index'])->name('index'); Route::post('/search', [RagSearchController::class, 'search'])->name('search'); }); + + Route::prefix('pptx')->name('pptx.')->group(function () { + Route::get('/', [PptxController::class, 'index'])->name('index'); + Route::get('/download', [PptxController::class, 'download'])->name('download'); + }); }); /*