feat: [additional] PPTX 관리 페이지 추가
- 파일시스템 스캔 기반 PPTX 목록 조회/다운로드 - 카테고리별 필터, 파일명 검색 기능 - 경로 트래버설 방지 보안 검증
This commit is contained in:
181
app/Http/Controllers/Additional/PptxController.php
Normal file
181
app/Http/Controllers/Additional/PptxController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
202
resources/views/additional/pptx/index.blade.php
Normal file
202
resources/views/additional/pptx/index.blade.php
Normal 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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user