feat: GTSIT 사이트 구축

This commit is contained in:
박성필
2026-08-10 21:19:10 +09:00
commit dbb35d35c4
87 changed files with 48246 additions and 0 deletions

573
public/index.php Normal file
View File

@@ -0,0 +1,573 @@
<?php
declare(strict_types=1);
$appRoot = (string) (getenv('GTSIT_APP_ROOT') ?: dirname(__DIR__));
require $appRoot . '/app/bootstrap.php';
use App\Repositories\PostRepository;
use App\Repositories\InquiryRepository;
use App\Repositories\AdminRepository;
use App\Auth\AdminAuth;
use App\Repositories\SiteSettingRepository;
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$basePath = (string) config('app.base_path', '');
if ($basePath !== '') {
if ($path !== $basePath && !str_starts_with($path, $basePath . '/')) {
http_response_code(404);
exit;
}
$path = substr($path, strlen($basePath)) ?: '/';
}
$path = rtrim($path, '/') ?: '/';
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if (PHP_SAPI === 'cli-server' && $path !== '/' && is_file(__DIR__ . $path)) {
return false;
}
$posts = [];
$databaseReady = true;
try {
$postRepository = new PostRepository();
} catch (Throwable) {
$postRepository = null;
$databaseReady = false;
}
if ($path === '/' && $method === 'GET') {
if ($postRepository !== null) {
$posts = $postRepository->latest(3);
}
render('home', [
'title' => '사무실 통신 인프라 전문',
'description' => '네트워크, 키폰, CCTV 설계부터 시공과 유지보수까지 책임지는 (주)지티에스정보통신입니다.',
'posts' => $posts,
]);
exit;
}
if ($path === '/blog' && $method === 'GET') {
$page = max(1, (int) ($_GET['page'] ?? 1));
$category = trim((string) ($_GET['category'] ?? ''));
$result = ['items' => [], 'total' => 0];
if ($postRepository !== null) {
$result = $postRepository->paginate($page, 6, $category ?: null);
}
render('blog/index', [
'title' => '시공 이야기와 정보',
'description' => '네트워크, 키폰, CCTV 시공 사례와 장비 선택 정보를 확인하세요.',
'posts' => $result['items'],
'total' => $result['total'],
'page' => $page,
'perPage' => 6,
'category' => $category,
]);
exit;
}
if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') {
$post = $postRepository?->findPublishedBySlug($matches[1]);
if ($post === null) {
http_response_code(404);
render('errors/404', ['title' => '게시글을 찾을 수 없습니다']);
exit;
}
render('blog/show', [
'title' => $post['title'],
'description' => $post['excerpt'],
'post' => $post,
]);
exit;
}
if ($path === '/contact' && $method === 'GET') {
render('contact', [
'title' => '1:1 비공개 상담 신청',
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
'errors' => [],
]);
exit;
}
if ($path === '/contact' && $method === 'POST') {
verify_csrf();
$allowedTypes = ['네트워크 구축', '키폰시스템', 'CCTV 설치', '시공·유지보수', '기타 문의'];
$data = [
'name' => trim((string) ($_POST['name'] ?? '')),
'phone' => trim((string) ($_POST['phone'] ?? '')),
'email' => trim((string) ($_POST['email'] ?? '')),
'type' => trim((string) ($_POST['type'] ?? '')),
'title' => trim((string) ($_POST['title'] ?? '')),
'body' => trim((string) ($_POST['body'] ?? '')),
'password' => (string) ($_POST['password'] ?? ''),
];
$errors = [];
if ($data['name'] === '' || mb_strlen($data['name']) > 120) $errors[] = '이름 또는 회사명을 확인해 주세요.';
if (!preg_match('/^(?:02-\d{3,4}-\d{4}|0\d{2}-\d{3,4}-\d{4}|050\d-\d{4}-\d{4}|1\d{3}-\d{4})$/D', $data['phone'])) $errors[] = '연락처 형식을 확인해 주세요.';
if ($data['email'] !== '' && filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) $errors[] = '이메일 형식을 확인해 주세요.';
if (!in_array($data['type'], $allowedTypes, true)) $errors[] = '문의 유형을 선택해 주세요.';
if ($data['title'] === '' || mb_strlen($data['title']) > 200) $errors[] = '제목을 200자 이내로 입력해 주세요.';
if ($data['body'] === '' || mb_strlen($data['body']) > 5000) $errors[] = '문의 내용을 5,000자 이내로 입력해 주세요.';
if (!preg_match('/^[0-9]{4}$/D', $data['password'])) $errors[] = '조회 PIN은 숫자 4자리로 입력해 주세요.';
if (($_POST['consent'] ?? '') !== '1') $errors[] = '개인정보 수집·이용 동의가 필요합니다.';
$lastSubmit = (int) ($_SESSION['last_inquiry_submit'] ?? 0);
if (time() - $lastSubmit < 20) $errors[] = '중복 접수를 방지하기 위해 잠시 후 다시 시도해 주세요.';
if ($errors) {
http_response_code(422);
render('contact', [
'title' => '1:1 비공개 상담 신청',
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
'errors' => $errors,
]);
exit;
}
try {
$ticket = (new InquiryRepository())->create($data);
$_SESSION['last_inquiry_submit'] = time();
$_SESSION['submitted_ticket'] = $ticket;
redirect('/contact/success');
} catch (Throwable) {
http_response_code(503);
render('contact', [
'title' => '1:1 비공개 상담 신청',
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
'errors' => ['현재 온라인 접수가 원활하지 않습니다. 대표번호로 문의해 주세요.'],
]);
exit;
}
}
if ($path === '/contact/success' && $method === 'GET') {
$ticket = $_SESSION['submitted_ticket'] ?? null;
if (!is_string($ticket) || $ticket === '') redirect('/contact');
unset($_SESSION['submitted_ticket']);
render('inquiries/success', ['title' => '문의 접수 완료', 'ticket' => $ticket]);
exit;
}
if ($path === '/inquiry/lookup' && $method === 'GET') {
render('inquiries/lookup', [
'title' => '문의 접수 확인',
'description' => '접수번호와 숫자 4자리 조회 PIN으로 상담 처리 상태를 확인하세요.',
'ticket' => trim((string) ($_GET['ticket'] ?? '')),
'error' => '',
]);
exit;
}
if ($path === '/inquiry/lookup' && $method === 'POST') {
verify_csrf();
$window = $_SESSION['lookup_rate'] ?? ['started' => time(), 'attempts' => 0];
if (time() - (int) $window['started'] > 900) $window = ['started' => time(), 'attempts' => 0];
if ((int) $window['attempts'] >= 5) {
http_response_code(429);
render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>'','error'=>'잠시 후 다시 시도해 주세요.']);
exit;
}
$ticket = strtoupper(trim((string) ($_POST['ticket'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
$inquiry = null;
$inquiryRepository = null;
try {
$inquiryRepository = new InquiryRepository();
$inquiry = $inquiryRepository->findForLookup($ticket);
} catch (Throwable) {}
if ($inquiry !== null && !empty($inquiry['lookup_locked_until'])) {
if (strtotime((string) $inquiry['lookup_locked_until']) > time()) {
http_response_code(429);
render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>$ticket,'error'=>'조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.']);
exit;
}
try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {}
}
$validPin = preg_match('/^[0-9]{4}$/D', $password) === 1;
if ($inquiry === null || !$validPin || !password_verify($password, $inquiry['password_hash'])) {
$window['attempts']++;
$_SESSION['lookup_rate'] = $window;
$locked = false;
if ($inquiry !== null && $inquiryRepository instanceof InquiryRepository) {
try { $locked = $inquiryRepository->recordLookupFailure((int) $inquiry['id']); } catch (Throwable) {}
}
http_response_code($locked ? 429 : 422);
render('inquiries/lookup', [
'title'=>'문의 접수 확인',
'ticket'=>$ticket,
'error'=>$locked ? '조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.' : '접수번호 또는 조회 PIN이 올바르지 않습니다.',
]);
exit;
}
try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {}
unset($_SESSION['lookup_rate'], $inquiry['id'], $inquiry['password_hash'], $inquiry['lookup_failed_attempts'], $inquiry['lookup_locked_until']);
render('inquiries/show', ['title'=>'문의 처리 상태','description'=>'비공개 상담 처리 상태입니다.','inquiry'=>$inquiry]);
exit;
}
if ($path === '/privacy' && $method === 'GET') {
render('privacy', [
'title' => '개인정보처리방침',
'description' => '(주)지티에스정보통신 개인정보처리방침입니다.',
]);
exit;
}
if ($path === '/admin/setup' && $method === 'GET') {
try {
$adminRepository = new AdminRepository();
if ($adminRepository->adminCount() > 0) redirect('/admin/login');
} catch (Throwable) {
http_response_code(503);
render('errors/404', ['title'=>'관리자 등록을 준비할 수 없습니다'], 'layouts/admin');
exit;
}
$token = (string) ($_GET['token'] ?? '');
$tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: '');
if ($token !== '' && $tokenFile !== '' && is_readable($tokenFile) && time() - (int) filemtime($tokenFile) <= 86400) {
$storedHash = trim((string) file_get_contents($tokenFile));
if ($storedHash !== '' && hash_equals($storedHash, hash('sha256', $token))) {
session_regenerate_id(true);
$_SESSION['admin_setup_verified'] = time();
redirect('/admin/setup');
}
}
if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) {
unset($_SESSION['admin_setup_verified']);
http_response_code(404);
render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin');
exit;
}
render('admin/setup', [
'title'=>'관리자 계정 등록',
'username'=>(string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201'),
'displayName'=>'관리자',
'errors'=>[],
], 'layouts/admin');
exit;
}
if ($path === '/admin/setup' && $method === 'POST') {
verify_csrf();
if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) {
unset($_SESSION['admin_setup_verified']);
http_response_code(404);
render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin');
exit;
}
$username = (string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201');
$displayName = trim((string) ($_POST['display_name'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$passwordConfirmation = (string) ($_POST['password_confirmation'] ?? '');
$errors = [];
if ($displayName === '' || mb_strlen($displayName) > 100) $errors[] = '표시 이름을 100자 이내로 입력해 주세요.';
if (strlen($password) < 12 || strlen($password) > 72 || !preg_match('/[A-Za-z]/', $password) || !preg_match('/[0-9]/', $password)) {
$errors[] = '비밀번호는 영문과 숫자를 포함해 12자 이상 입력해 주세요.';
}
if (!hash_equals($password, $passwordConfirmation)) $errors[] = '비밀번호 확인이 일치하지 않습니다.';
if ($errors) {
http_response_code(422);
render('admin/setup', compact('username', 'displayName', 'errors') + ['title'=>'관리자 계정 등록'], 'layouts/admin');
exit;
}
try {
$adminRepository = new AdminRepository();
if ($adminRepository->adminCount() > 0) redirect('/admin/login');
$adminRepository->createAdmin($username, $displayName, $password);
$tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: '');
if ($tokenFile !== '' && is_file($tokenFile)) unlink($tokenFile);
unset($_SESSION['admin_setup_verified']);
$_SESSION['admin_created'] = true;
redirect('/admin/login');
} catch (Throwable) {
http_response_code(503);
render('admin/setup', [
'title'=>'관리자 계정 등록','username'=>$username,'displayName'=>$displayName,
'errors'=>['계정을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'],
], 'layouts/admin');
exit;
}
}
if ($path === '/admin/login' && $method === 'GET') {
if (AdminAuth::check()) redirect('/admin');
$notice = !empty($_SESSION['admin_created']) ? '관리자 계정이 생성되었습니다. 설정한 비밀번호로 로그인해 주세요.' : '';
unset($_SESSION['admin_created']);
render('admin/login', ['title'=>'관리자 로그인','error'=>'','notice'=>$notice,'username'=>'gts0201'], 'layouts/admin');
exit;
}
if ($path === '/admin/login' && $method === 'POST') {
verify_csrf();
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$loginRate = $_SESSION['admin_login_rate'] ?? ['started'=>time(),'attempts'=>0];
if (time() - (int)$loginRate['started'] > 900) $loginRate = ['started'=>time(),'attempts'=>0];
if ((int)$loginRate['attempts'] >= 10) {
http_response_code(429);
render('admin/login', ['title'=>'관리자 로그인','error'=>'로그인 시도가 많습니다. 잠시 후 다시 시도해 주세요.','notice'=>'','username'=>$username], 'layouts/admin');
exit;
}
$authenticated = false;
try { $authenticated = (new AdminAuth())->attempt($username, $password); } catch (Throwable) {}
if ($authenticated) {
unset($_SESSION['admin_login_rate']);
redirect('/admin');
}
$loginRate['attempts']++;
$_SESSION['admin_login_rate'] = $loginRate;
http_response_code(422);
render('admin/login', ['title'=>'관리자 로그인','error'=>'아이디 또는 비밀번호를 확인해 주세요.','notice'=>'','username'=>$username], 'layouts/admin');
exit;
}
if ($path === '/admin/logout' && $method === 'POST') {
verify_csrf();
AdminAuth::logout();
redirect('/admin/login');
}
if (str_starts_with($path, '/admin')) {
AdminAuth::requireLogin();
try { $adminRepository = new AdminRepository(); } catch (Throwable) {
http_response_code(503);
render('errors/404', ['title'=>'관리 데이터를 불러올 수 없습니다'], 'layouts/admin');
exit;
}
if ($path === '/admin' && $method === 'GET') {
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts()], 'layouts/admin');
exit;
}
if ($path === '/admin/posts' && $method === 'GET') {
render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin');
exit;
}
if ($path === '/admin/posts/new' && $method === 'GET') {
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[]], 'layouts/admin');
exit;
}
if ($path === '/admin/posts/new' && $method === 'POST') {
verify_csrf();
$postData = collect_post_input();
$errors = validate_post_input($postData, $adminRepository);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError;
if ($image !== null && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.';
if ($errors) {
http_response_code(422);
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>$errors], 'layouts/admin');
exit;
}
$newImagePath = null;
try {
if ($image !== null) $newImagePath = store_post_image($image);
$adminRepository->savePostWithImage($postData, null, $newImagePath, $postData['image_alt']);
} catch (Throwable) {
if ($newImagePath !== null) remove_stored_image($newImagePath);
http_response_code(503);
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin');
exit;
}
redirect('/admin/posts');
}
if (preg_match('#^/admin/posts/(\d+)/edit$#', $path, $adminPostMatch) === 1) {
$postId = (int) $adminPostMatch[1];
$post = $adminRepository->findPost($postId);
if (!$post) {
http_response_code(404);
render('errors/404', ['title'=>'게시글을 찾을 수 없습니다'], 'layouts/admin');
exit;
}
if ($method === 'GET') {
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[]], 'layouts/admin');
exit;
}
if ($method === 'POST') {
verify_csrf();
$postData = collect_post_input();
$errors = validate_post_input($postData, $adminRepository, $postId);
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
if ($imageError !== null) $errors[] = $imageError;
if (($image !== null || !empty($post['image_path'])) && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.';
if ($errors) {
http_response_code(422);
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>$errors], 'layouts/admin');
exit;
}
$newImagePath = null;
try {
if ($image !== null) $newImagePath = store_post_image($image);
$result = $adminRepository->savePostWithImage($postData, $postId, $newImagePath, $postData['image_alt']);
foreach ($result['old_paths'] as $oldPath) remove_stored_image((string) $oldPath);
} catch (Throwable) {
if ($newImagePath !== null) remove_stored_image($newImagePath);
http_response_code(503);
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin');
exit;
}
redirect('/admin/posts');
}
}
if (preg_match('#^/admin/posts/(\d+)/delete$#', $path, $adminDeleteMatch) === 1 && $method === 'POST') {
verify_csrf();
$adminRepository->deletePost((int) $adminDeleteMatch[1]);
redirect('/admin/posts');
}
if ($path === '/admin/inquiries' && $method === 'GET') {
render('admin/inquiries/index', ['title'=>'문의 관리','inquiries'=>$adminRepository->inquiries()], 'layouts/admin');
exit;
}
if (preg_match('#^/admin/inquiries/(\d+)$#', $path, $adminInquiryMatch) === 1) {
$inquiryId = (int) $adminInquiryMatch[1];
$inquiry = $adminRepository->findInquiry($inquiryId);
if (!$inquiry) {
http_response_code(404);
render('errors/404', ['title'=>'문의를 찾을 수 없습니다'], 'layouts/admin');
exit;
}
if ($method === 'GET') {
render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin');
exit;
}
if ($method === 'POST') {
verify_csrf();
$allowedStatuses = ['pending','in_progress','replied','closed'];
$status = (string) ($_POST['status'] ?? 'pending');
$reply = trim((string) ($_POST['reply'] ?? ''));
if (!in_array($status, $allowedStatuses, true) || mb_strlen($reply) > 5000) {
http_response_code(422);
render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin');
exit;
}
if ($reply !== '' && $status === 'pending') $status = 'replied';
$adminRepository->updateInquiry($inquiryId, $status, $reply);
redirect('/admin/inquiries/' . $inquiryId);
}
}
if ($path === '/admin/company') {
$defaults = [
'legal_name'=>(string)config('app.legal_name'),'ceo'=>'서선배',
'phone'=>(string)config('app.contact.phone'),'phone_sub'=>(string)config('app.contact.phone_sub'),
'fax'=>(string)config('app.contact.fax'),'email'=>(string)config('app.contact.email'),
'address'=>(string)config('app.contact.address'),'bizno'=>'206-86-70582',
'business_hours'=>(string)config('app.contact.business_hours'),
];
$settingRepository = new SiteSettingRepository();
$settings = array_merge($defaults, $settingRepository->all());
if ($method === 'GET') {
render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>[]], 'layouts/admin');
exit;
}
if ($method === 'POST') {
verify_csrf();
$submitted = [];
foreach (array_keys($defaults) as $key) $submitted[$key] = trim((string)($_POST[$key]??''));
$errors = [];
if ($submitted['legal_name']==='' || mb_strlen($submitted['legal_name'])>120) $errors[]='법인명을 확인해 주세요.';
if (!preg_match('/^[0-9+() -]{8,30}$/',$submitted['phone'])) $errors[]='대표번호를 확인해 주세요.';
if ($submitted['email']!=='' && filter_var($submitted['email'],FILTER_VALIDATE_EMAIL)===false) $errors[]='이메일 형식을 확인해 주세요.';
if (mb_strlen($submitted['address'])>255) $errors[]='주소를 255자 이내로 입력해 주세요.';
if ($errors) {
http_response_code(422);
render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>$errors], 'layouts/admin');
exit;
}
$settingRepository->save($submitted);
redirect('/admin/company');
}
}
http_response_code(404);
render('errors/404', ['title'=>'관리자 페이지를 찾을 수 없습니다'], 'layouts/admin');
exit;
}
http_response_code(404);
render('errors/404', ['title' => '페이지를 찾을 수 없습니다']);
function collect_post_input(): array
{
return [
'title'=>trim((string)($_POST['title']??'')),
'slug'=>trim((string)($_POST['slug']??'')),
'category'=>trim((string)($_POST['category']??'')),
'excerpt'=>trim((string)($_POST['excerpt']??'')),
'body'=>trim((string)($_POST['body']??'')),
'author'=>trim((string)($_POST['author']??'')),
'status'=>(string)($_POST['status']??'draft'),
'published_at'=>'',
'image_alt'=>trim((string)($_POST['image_alt']??'')),
];
}
function validate_post_input(array $data, AdminRepository $repository, ?int $exceptId = null): array
{
$errors = [];
if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.';
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $data['slug'])) $errors[]='slug는 영문 소문자, 숫자, 하이픈으로 입력해 주세요.';
elseif ($repository->slugExists($data['slug'], $exceptId)) $errors[]='이미 사용 중인 slug입니다.';
if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.';
if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.';
if ($data['body']==='') $errors[]='본문을 입력해 주세요.';
if ($data['author']==='' || mb_strlen($data['author'])>100) $errors[]='작성자를 확인해 주세요.';
if (!in_array($data['status'], ['draft','published'], true)) $errors[]='공개 상태를 확인해 주세요.';
if (mb_strlen($data['image_alt'])>255) $errors[]='이미지 대체 텍스트를 255자 이내로 입력해 주세요.';
return $errors;
}
function prepare_post_image(?array $file): array
{
if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null];
if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) {
return [null, '이미지 업로드 요청이 올바르지 않습니다.'];
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.'];
if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, '대표 이미지는 8MB 이하만 등록할 수 있습니다.'];
$tmpName = (string) $file['tmp_name'];
if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpName);
$extensions = ['image/jpeg'=>'jpg', 'image/png'=>'png', 'image/webp'=>'webp'];
if (!is_string($mime) || !isset($extensions[$mime])) return [null, 'JPG, PNG, WebP 이미지만 등록할 수 있습니다.'];
$dimensions = getimagesize($tmpName);
if ($dimensions === false || $dimensions[0] < 1 || $dimensions[1] < 1 || $dimensions[0] > 6000 || $dimensions[1] > 6000) {
return [null, '이미지 크기는 최대 6000×6000px까지 등록할 수 있습니다.'];
}
if (($dimensions['mime'] ?? '') !== $mime) return [null, '이미지 파일의 형식이 올바르지 않습니다.'];
return [['tmp_name'=>$tmpName, 'extension'=>$extensions[$mime]], null];
}
function store_post_image(array $image): string
{
$relativeDirectory = '/uploads/posts/' . date('Y/m');
$publicRoot = rtrim((string) config('app.public_root', __DIR__), '/');
$absoluteDirectory = $publicRoot . $relativeDirectory;
if (!is_dir($absoluteDirectory) && !mkdir($absoluteDirectory, 0750, true) && !is_dir($absoluteDirectory)) {
throw new RuntimeException('Upload directory could not be created.');
}
$relativePath = $relativeDirectory . '/' . bin2hex(random_bytes(20)) . '.' . $image['extension'];
if (!move_uploaded_file($image['tmp_name'], $publicRoot . $relativePath)) {
throw new RuntimeException('Uploaded file could not be moved.');
}
chmod($publicRoot . $relativePath, 0640);
return $relativePath;
}
function remove_stored_image(string $path): void
{
if (!str_starts_with($path, '/uploads/posts/')) return;
$absolutePath = rtrim((string) config('app.public_root', __DIR__), '/') . $path;
if (is_file($absolutePath)) unlink($absolutePath);
}