Files
gtsit/public/index.php
2026-08-10 21:19:10 +09:00

574 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}