feat: [additional] PPTX 관리 페이지 추가

- 파일시스템 스캔 기반 PPTX 목록 조회/다운로드
- 카테고리별 필터, 파일명 검색 기능
- 경로 트래버설 방지 보안 검증
This commit is contained in:
김보곤
2026-02-23 07:57:39 +09:00
parent 576b1d9f6b
commit 1bb2ac32f9
3 changed files with 389 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers\Additional;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 추가기능 > 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';
}
}

View File

@@ -0,0 +1,202 @@
@extends('layouts.app')
@section('title', 'PPTX 관리')
@push('styles')
<style>
.pptx { max-width: 1100px; margin: 0 auto; padding: 32px 20px 48px; }
/* 헤더 */
.pptx-header { text-align: center; margin-bottom: 32px; }
.pptx-header h1 { font-size: 1.75rem; font-weight: 700; color: #1e293b; margin-bottom: 6px; }
.pptx-header p { color: #64748b; font-size: 0.95rem; }
/* 통계 바 */
.pptx-stats { display: flex; gap: 16px; justify-content: center; margin-bottom: 28px; }
.pptx-stat { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 24px; display: flex; align-items: center; gap: 10px; }
.pptx-stat-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.pptx-stat-icon svg { width: 20px; height: 20px; }
.pptx-stat-icon.count { background: #dbeafe; color: #2563eb; }
.pptx-stat-icon.size { background: #dcfce7; color: #16a34a; }
.pptx-stat-num { font-size: 1.3rem; font-weight: 700; color: #1e293b; }
.pptx-stat-label { font-size: 0.75rem; color: #94a3b8; }
/* 검색 + 필터 바 */
.pptx-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 24px; }
.pptx-search { flex: 1; min-width: 200px; position: relative; }
.pptx-search input { width: 100%; padding: 10px 14px 10px 38px; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 0.88rem; outline: none; transition: border-color 0.2s; background: #fff; }
.pptx-search input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); }
.pptx-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: #94a3b8; }
/* 필터 탭 */
.pptx-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.pptx-filter-btn { padding: 6px 14px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; font-size: 0.8rem; color: #64748b; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.pptx-filter-btn:hover { border-color: #3b82f6; color: #3b82f6; }
.pptx-filter-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
/* 파일 목록 */
.pptx-list { display: flex; flex-direction: column; gap: 10px; }
.pptx-item { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 18px 20px; display: flex; align-items: center; gap: 16px; transition: all 0.2s; }
.pptx-item:hover { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59,130,246,0.08); }
/* 파일 아이콘 */
.pptx-icon { width: 44px; height: 44px; border-radius: 10px; background: #fff7ed; color: #ea580c; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.pptx-icon svg { width: 22px; height: 22px; }
/* 파일 정보 */
.pptx-info { flex: 1; min-width: 0; }
.pptx-name { font-size: 0.92rem; font-weight: 600; color: #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pptx-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 5px; align-items: center; }
.pptx-badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 0.7rem; font-weight: 500; }
.pptx-badge.cat { background: #dbeafe; color: #1d4ed8; }
.pptx-badge.src { background: #f1f5f9; color: #64748b; }
.pptx-meta-text { font-size: 0.75rem; color: #94a3b8; }
.pptx-meta-sep { color: #e2e8f0; font-size: 0.7rem; }
/* 다운로드 버튼 */
.pptx-dl { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; font-size: 0.8rem; font-weight: 500; color: #334155; text-decoration: none; transition: all 0.2s; flex-shrink: 0; }
.pptx-dl:hover { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.pptx-dl svg { width: 14px; height: 14px; }
/* 빈 상태 */
.pptx-empty { text-align: center; padding: 60px 20px; color: #94a3b8; }
.pptx-empty svg { width: 48px; height: 48px; margin: 0 auto 12px; color: #cbd5e1; }
.pptx-empty p { font-size: 0.9rem; }
@media (max-width: 768px) {
.pptx-stats { flex-direction: column; align-items: stretch; }
.pptx-item { flex-direction: column; align-items: flex-start; gap: 12px; }
.pptx-dl { align-self: flex-end; }
}
</style>
@endpush
@section('content')
<div class="pptx">
{{-- 헤더 --}}
<div class="pptx-header">
<h1>PPTX 관리</h1>
<p>회사 프레젠테이션 자료를 곳에서 관리합니다</p>
</div>
{{-- 통계 --}}
<div class="pptx-stats">
<div class="pptx-stat">
<div class="pptx-stat-icon count">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</div>
<div>
<div class="pptx-stat-num">{{ $totalCount }}</div>
<div class="pptx-stat-label">전체 파일</div>
</div>
</div>
<div class="pptx-stat">
<div class="pptx-stat-icon size">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
</div>
<div>
<div class="pptx-stat-num">{{ $totalSize }}</div>
<div class="pptx-stat-label"> 크기</div>
</div>
</div>
</div>
{{-- 검색 + 필터 --}}
<div class="pptx-toolbar">
<div class="pptx-search">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input type="text" id="pptxSearch" placeholder="파일명 검색..." autocomplete="off">
</div>
<div class="pptx-filters">
<button class="pptx-filter-btn active" data-category="all">전체</button>
@foreach ($categories as $cat)
<button class="pptx-filter-btn" data-category="{{ $cat }}">{{ $cat }}</button>
@endforeach
</div>
</div>
{{-- 파일 목록 --}}
<div class="pptx-list" id="pptxList">
@forelse ($files as $file)
<div class="pptx-item" data-category="{{ $file['category'] }}" data-name="{{ mb_strtolower($file['name']) }}">
<div class="pptx-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</div>
<div class="pptx-info">
<div class="pptx-name" title="{{ $file['name'] }}">{{ $file['name'] }}</div>
<div class="pptx-meta">
<span class="pptx-badge cat">{{ $file['category'] }}</span>
<span class="pptx-badge src">{{ $file['source_dir'] }}</span>
<span class="pptx-meta-sep">|</span>
<span class="pptx-meta-text">{{ $file['size'] }}</span>
<span class="pptx-meta-sep">|</span>
<span class="pptx-meta-text">{{ $file['modified_date'] }}</span>
</div>
</div>
<a href="{{ route('additional.pptx.download', ['file' => $file['path']]) }}" class="pptx-dl">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
다운로드
</a>
</div>
@empty
<div class="pptx-empty">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<p>PPTX 파일이 없습니다</p>
</div>
@endforelse
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.getElementById('pptxSearch');
const filterBtns = document.querySelectorAll('.pptx-filter-btn');
const items = document.querySelectorAll('.pptx-item');
let activeCategory = 'all';
function filterItems() {
const query = searchInput.value.toLowerCase().trim();
let visibleCount = 0;
items.forEach(function (item) {
const name = item.dataset.name;
const category = item.dataset.category;
const matchCategory = activeCategory === 'all' || category === activeCategory;
const matchSearch = !query || name.includes(query);
const visible = matchCategory && matchSearch;
item.style.display = visible ? '' : 'none';
if (visible) visibleCount++;
});
// 빈 상태 메시지
let emptyEl = document.getElementById('pptxFilterEmpty');
if (visibleCount === 0) {
if (!emptyEl) {
emptyEl = document.createElement('div');
emptyEl.id = 'pptxFilterEmpty';
emptyEl.className = 'pptx-empty';
emptyEl.innerHTML = '<p>검색 결과가 없습니다</p>';
document.getElementById('pptxList').appendChild(emptyEl);
}
emptyEl.style.display = '';
} else if (emptyEl) {
emptyEl.style.display = 'none';
}
}
filterBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
filterBtns.forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
activeCategory = btn.dataset.category;
filterItems();
});
});
searchInput.addEventListener('input', filterItems);
});
</script>
@endpush

View File

@@ -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');
});
});
/*