Initial profile site commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
// =====================================================
|
||||
// 로그인
|
||||
// =====================================================
|
||||
if ($method === 'POST' && $action === 'login') {
|
||||
$input = get_json_input();
|
||||
$password = $input['password'] ?? '';
|
||||
|
||||
if (empty($password)) {
|
||||
json_response(['error' => '비밀번호를 입력하세요'], 400);
|
||||
}
|
||||
|
||||
// 무차별 대입 방지 - 간단한 딜레이
|
||||
usleep(500000); // 0.5초
|
||||
|
||||
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||
// 세션 고정 공격 방지
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['login_time'] = time();
|
||||
json_response(['success' => true, 'message' => '로그인 성공']);
|
||||
} else {
|
||||
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 로그아웃
|
||||
// =====================================================
|
||||
if ($method === 'POST' && $action === 'logout') {
|
||||
session_destroy();
|
||||
json_response(['success' => true]);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 인증 상태 확인
|
||||
// =====================================================
|
||||
if ($method === 'GET' && $action === 'check') {
|
||||
$authenticated = isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true;
|
||||
|
||||
// 타임아웃 체크
|
||||
if ($authenticated && isset($_SESSION['login_time'])) {
|
||||
if ((time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
||||
session_destroy();
|
||||
$authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['authenticated' => $authenticated]);
|
||||
}
|
||||
|
||||
json_response(['error' => 'Invalid action'], 400);
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
|
||||
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// 카테고리 정규화 (parent_id 필드 보장)
|
||||
function normalize_category($cat) {
|
||||
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
|
||||
if (!isset($cat['order'])) $cat['order'] = 0;
|
||||
if (!isset($cat['color'])) $cat['color'] = '#00f2ff';
|
||||
return $cat;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GET: 카테고리 목록
|
||||
// =====================================================
|
||||
if ($method === 'GET') {
|
||||
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
|
||||
$categories = array_map('normalize_category', $categories);
|
||||
|
||||
usort($categories, function($a, $b) {
|
||||
// parent끼리 먼저 정렬되도록
|
||||
$aParent = $a['parent_id'] ?? 0;
|
||||
$bParent = $b['parent_id'] ?? 0;
|
||||
if ($aParent !== $bParent) return $aParent - $bParent;
|
||||
return ($a['order'] ?? 0) - ($b['order'] ?? 0);
|
||||
});
|
||||
|
||||
json_response($categories);
|
||||
}
|
||||
|
||||
require_auth();
|
||||
|
||||
// =====================================================
|
||||
// POST: 카테고리 추가
|
||||
// =====================================================
|
||||
if ($method === 'POST') {
|
||||
$input = get_json_input();
|
||||
$name = trim($input['name'] ?? '');
|
||||
$color = trim($input['color'] ?? '#00f2ff');
|
||||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
|
||||
? intval($input['parent_id'])
|
||||
: null;
|
||||
|
||||
if (empty($name)) {
|
||||
json_response(['error' => '카테고리 이름은 필수입니다'], 400);
|
||||
}
|
||||
|
||||
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
|
||||
|
||||
// parent_id 유효성 검증
|
||||
if ($parentId !== null) {
|
||||
$parentFound = false;
|
||||
foreach ($categories as $c) {
|
||||
if (($c['id'] ?? 0) === $parentId) {
|
||||
// 부모가 또 다른 부모를 가지면 안 됨 (2단계로 제한)
|
||||
if (!empty($c['parent_id'])) {
|
||||
json_response(['error' => '서브 카테고리 아래에는 카테고리를 만들 수 없습니다'], 400);
|
||||
}
|
||||
$parentFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$parentFound) {
|
||||
json_response(['error' => '부모 카테고리를 찾을 수 없습니다'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$maxId = 0;
|
||||
$maxOrder = 0;
|
||||
foreach ($categories as $c) {
|
||||
if (($c['id'] ?? 0) > $maxId) $maxId = $c['id'];
|
||||
// 같은 부모 내에서 max order
|
||||
if (($c['parent_id'] ?? null) === $parentId && ($c['order'] ?? 0) > $maxOrder) {
|
||||
$maxOrder = $c['order'];
|
||||
}
|
||||
}
|
||||
|
||||
$newCategory = [
|
||||
'id' => $maxId + 1,
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'parent_id' => $parentId,
|
||||
'order' => $maxOrder + 1
|
||||
];
|
||||
|
||||
$categories[] = $newCategory;
|
||||
|
||||
if (write_json_safe(CATEGORIES_FILE, $categories)) {
|
||||
json_response(['success' => true, 'category' => $newCategory]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// PUT: 카테고리 수정
|
||||
// =====================================================
|
||||
if ($method === 'PUT') {
|
||||
$input = get_json_input();
|
||||
$id = intval($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $key => $cat) {
|
||||
if ($cat['id'] === $id) {
|
||||
if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']);
|
||||
if (isset($input['color'])) $categories[$key]['color'] = trim($input['color']);
|
||||
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
|
||||
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
|
||||
if (array_key_exists('parent_id', $input)) {
|
||||
$newParent = $input['parent_id'];
|
||||
$newParent = ($newParent === '' || $newParent === null) ? null : intval($newParent);
|
||||
|
||||
// 자기 자신을 부모로 설정 불가
|
||||
if ($newParent === $id) {
|
||||
json_response(['error' => '자기 자신을 부모로 설정할 수 없습니다'], 400);
|
||||
}
|
||||
|
||||
// 자식이 있는 카테고리는 다른 카테고리의 자식이 될 수 없음
|
||||
$hasChildren = false;
|
||||
foreach ($categories as $c) {
|
||||
if (($c['parent_id'] ?? null) === $id) {
|
||||
$hasChildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($newParent !== null && $hasChildren) {
|
||||
json_response(['error' => '하위 카테고리가 있는 카테고리는 다른 카테고리의 하위로 이동할 수 없습니다'], 400);
|
||||
}
|
||||
|
||||
$categories[$key]['parent_id'] = $newParent;
|
||||
}
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
json_response(['error' => '카테고리를 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(CATEGORIES_FILE, $categories)) {
|
||||
json_response(['success' => true]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DELETE: 카테고리 삭제
|
||||
// =====================================================
|
||||
if ($method === 'DELETE') {
|
||||
$id = intval($_GET['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
|
||||
|
||||
// 자식 카테고리 ID 수집
|
||||
$allIdsToDelete = [$id];
|
||||
foreach ($categories as $c) {
|
||||
if (($c['parent_id'] ?? null) === $id) {
|
||||
$allIdsToDelete[] = $c['id'];
|
||||
}
|
||||
}
|
||||
|
||||
// 해당 카테고리(들)을 사용하는 글이 있는지 확인
|
||||
$learnings = read_json_safe(DATA_DIR . '/learning.json') ?? [];
|
||||
$usedCount = 0;
|
||||
foreach ($learnings as $l) {
|
||||
if (in_array(($l['category_id'] ?? 0), $allIdsToDelete)) $usedCount++;
|
||||
}
|
||||
|
||||
if ($usedCount > 0) {
|
||||
$isParent = count($allIdsToDelete) > 1;
|
||||
$msg = $isParent
|
||||
? "이 카테고리(또는 하위 카테고리)를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요."
|
||||
: "이 카테고리를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요.";
|
||||
json_response(['error' => $msg], 400);
|
||||
}
|
||||
|
||||
$filtered = array_values(array_filter($categories, function($c) use ($allIdsToDelete) {
|
||||
return !in_array(($c['id'] ?? 0), $allIdsToDelete);
|
||||
}));
|
||||
|
||||
if (count($filtered) === count($categories)) {
|
||||
json_response(['error' => '카테고리를 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(CATEGORIES_FILE, $filtered)) {
|
||||
json_response(['success' => true, 'deleted_count' => count($allIdsToDelete)]);
|
||||
} else {
|
||||
json_response(['error' => '삭제에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['error' => 'Method not allowed'], 405);
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
// =====================================================
|
||||
// 설정 파일
|
||||
// =====================================================
|
||||
// ⚠️ 중요: 처음 사용 시 아래 ADMIN_PASSWORD_HASH를 변경하세요
|
||||
// 비밀번호 해시 생성 방법:
|
||||
// 브라우저에서 generate_password.php 접속 후 원하는 비번 입력
|
||||
// 또는 터미널에서: php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
|
||||
// =====================================================
|
||||
|
||||
// 기본 비밀번호: "admin1234" (반드시 변경하세요!)
|
||||
define('ADMIN_PASSWORD_HASH', '$2y$10$Wj/5fxQX90AlvyVPBfE0te2aUbysSBlE/Umm7EluG880rqcRUlHGm');
|
||||
|
||||
// 데이터 파일 경로
|
||||
define('DATA_DIR', __DIR__ . '/../data');
|
||||
define('UPLOADS_DIR', __DIR__ . '/../uploads');
|
||||
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
|
||||
define('PROFILE_FILE', DATA_DIR . '/profile.json');
|
||||
|
||||
// 세션 설정
|
||||
define('SESSION_LIFETIME', 3600 * 4); // 4시간
|
||||
|
||||
// CORS 및 JSON 헤더
|
||||
function set_json_headers() {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
}
|
||||
|
||||
// JSON 파일을 락 걸고 읽기
|
||||
function read_json_safe($filepath) {
|
||||
if (!file_exists($filepath)) {
|
||||
return null;
|
||||
}
|
||||
$fp = fopen($filepath, 'r');
|
||||
if (!$fp) return null;
|
||||
|
||||
if (flock($fp, LOCK_SH)) {
|
||||
$content = stream_get_contents($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return json_decode($content, true);
|
||||
}
|
||||
fclose($fp);
|
||||
return null;
|
||||
}
|
||||
|
||||
// JSON 파일을 락 걸고 쓰기
|
||||
function write_json_safe($filepath, $data) {
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) return false;
|
||||
|
||||
$fp = fopen($filepath, 'c+');
|
||||
if (!$fp) return false;
|
||||
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, $json);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 인증 체크
|
||||
function require_auth() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 세션 타임아웃 체크
|
||||
if (isset($_SESSION['login_time']) &&
|
||||
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
||||
session_destroy();
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Session expired']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 입력 받기
|
||||
function get_json_input() {
|
||||
$input = file_get_contents('php://input');
|
||||
return json_decode($input, true);
|
||||
}
|
||||
|
||||
// 응답 헬퍼
|
||||
function json_response($data, $status = 200) {
|
||||
http_response_code($status);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
require_auth();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// =====================================================
|
||||
// POST: 특정 폴더 안의 파일들 삭제
|
||||
// 또는 특정 파일 URL들 삭제
|
||||
// =====================================================
|
||||
if ($method !== 'POST') {
|
||||
json_response(['error' => 'POST만 허용됩니다'], 405);
|
||||
}
|
||||
|
||||
$input = get_json_input();
|
||||
|
||||
// 안전 가드: uploads 디렉토리 밖으로 못 나가게
|
||||
function is_safe_uploads_path($path) {
|
||||
$real_uploads = realpath(UPLOADS_DIR);
|
||||
$real_target = realpath($path);
|
||||
if ($real_uploads === false) return false;
|
||||
if ($real_target === false) return false;
|
||||
return strpos($real_target, $real_uploads) === 0;
|
||||
}
|
||||
|
||||
$deleted = [];
|
||||
$failed = [];
|
||||
|
||||
// 1) URL 리스트로 개별 파일 삭제
|
||||
if (isset($input['urls']) && is_array($input['urls'])) {
|
||||
foreach ($input['urls'] as $url) {
|
||||
$url = trim($url);
|
||||
if ($url === '') continue;
|
||||
|
||||
// uploads/ 로 시작하는 상대 경로만 허용
|
||||
if (!preg_match('#^uploads/#', $url)) {
|
||||
$failed[] = ['url' => $url, 'reason' => 'invalid path'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 외부 URL은 건너뛰기 (http://, https:// 등)
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 절대 경로 만들기
|
||||
$relative = preg_replace('#^uploads/#', '', $url);
|
||||
$full_path = UPLOADS_DIR . '/' . $relative;
|
||||
|
||||
if (!file_exists($full_path)) {
|
||||
$failed[] = ['url' => $url, 'reason' => 'not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_safe_uploads_path($full_path)) {
|
||||
$failed[] = ['url' => $url, 'reason' => 'unsafe path'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (@unlink($full_path)) {
|
||||
$deleted[] = $url;
|
||||
} else {
|
||||
$failed[] = ['url' => $url, 'reason' => 'unlink failed'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 폴더 삭제 (프로젝트 폴더 통째로)
|
||||
if (isset($input['folder'])) {
|
||||
$folder = trim($input['folder']);
|
||||
if ($folder !== '' && !preg_match('#[/\\\\]#', $folder)) {
|
||||
$folder_path = UPLOADS_DIR . '/' . $folder;
|
||||
if (is_dir($folder_path) && is_safe_uploads_path($folder_path)) {
|
||||
// 폴더 안의 파일들 삭제
|
||||
$files = glob($folder_path . '/*');
|
||||
foreach ($files as $f) {
|
||||
if (is_file($f)) {
|
||||
if (@unlink($f)) {
|
||||
$deleted[] = $folder . '/' . basename($f);
|
||||
} else {
|
||||
$failed[] = ['url' => $folder . '/' . basename($f), 'reason' => 'unlink failed'];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 빈 폴더는 삭제
|
||||
@rmdir($folder_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json_response([
|
||||
'success' => true,
|
||||
'deleted_count' => count($deleted),
|
||||
'failed_count' => count($failed),
|
||||
'deleted' => $deleted,
|
||||
'failed' => $failed
|
||||
]);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// error_config.php — API 파일 최상단에 require 추가
|
||||
// 위치: /web/my_profile/api/error_config.php
|
||||
//
|
||||
// 사용법: 각 api/*.php 파일 최상단에 아래 한 줄 추가
|
||||
// require_once __DIR__ . '/error_config.php';
|
||||
// ============================================================
|
||||
|
||||
// 로그 파일 경로 (uploads 바깥, 웹 접근 불가 경로 권장)
|
||||
define('LOG_FILE', dirname(__DIR__) . '/logs/app.log');
|
||||
|
||||
// logs/ 디렉토리 자동 생성
|
||||
$log_dir = dirname(LOG_FILE);
|
||||
if (!is_dir($log_dir)) {
|
||||
mkdir($log_dir, 0750, true);
|
||||
}
|
||||
|
||||
// PHP 에러를 파일에만 기록 (화면 노출 X)
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('error_log', LOG_FILE);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// ── 커스텀 로거 ───────────────────────────────────────────
|
||||
/**
|
||||
* 앱 로그 기록
|
||||
*
|
||||
* @param string $level 'INFO' | 'WARN' | 'ERROR'
|
||||
* @param string $msg 메시지
|
||||
* @param array $ctx 추가 컨텍스트 (선택)
|
||||
*/
|
||||
function app_log(string $level, string $msg, array $ctx = []): void {
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '-';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? '-';
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '-';
|
||||
$ctx_str = $ctx ? ' | ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) : '';
|
||||
$line = "[$ts][$level] $ip $method $uri | $msg$ctx_str" . PHP_EOL;
|
||||
file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
// ── 로그 로테이션 (10MB 초과 시 백업) ─────────────────────
|
||||
if (file_exists(LOG_FILE) && filesize(LOG_FILE) > 10 * 1024 * 1024) {
|
||||
rename(LOG_FILE, LOG_FILE . '.' . date('Ymd'));
|
||||
}
|
||||
|
||||
// ── 미처리 예외 핸들러 ────────────────────────────────────
|
||||
set_exception_handler(function (Throwable $e) {
|
||||
app_log('ERROR', $e->getMessage(), [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => '서버 오류가 발생했습니다.']);
|
||||
exit;
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
|
||||
define('LEARNING_FILE', DATA_DIR . '/learning.json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// =====================================================
|
||||
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
|
||||
// =====================================================
|
||||
if ($method === 'GET') {
|
||||
$learnings = read_json_safe(LEARNING_FILE) ?? [];
|
||||
|
||||
// 단일 글 조회: ?id=N
|
||||
if (isset($_GET['id'])) {
|
||||
$id = intval($_GET['id']);
|
||||
foreach ($learnings as $l) {
|
||||
if (($l['id'] ?? 0) === $id) {
|
||||
// is_learning 필드는 더이상 의미 없음 (제거)
|
||||
unset($l['is_learning']);
|
||||
json_response($l);
|
||||
}
|
||||
}
|
||||
json_response(['error' => '글을 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
// is_learning 필드 마이그레이션 (제거)
|
||||
foreach ($learnings as &$l) {
|
||||
unset($l['is_learning']);
|
||||
}
|
||||
unset($l);
|
||||
|
||||
// 카테고리 필터: ?category_id=N (단일 또는 카테고리 + 모든 자식)
|
||||
if (isset($_GET['category_id'])) {
|
||||
$catId = intval($_GET['category_id']);
|
||||
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
|
||||
|
||||
// 자식 카테고리 ID도 포함
|
||||
$catIds = [$catId];
|
||||
foreach ($categories as $c) {
|
||||
if (($c['parent_id'] ?? null) === $catId) {
|
||||
$catIds[] = $c['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$learnings = array_values(array_filter($learnings, function($l) use ($catIds) {
|
||||
return in_array(($l['category_id'] ?? 0), $catIds);
|
||||
}));
|
||||
}
|
||||
|
||||
// 최신순 정렬
|
||||
usort($learnings, function($a, $b) {
|
||||
$aDate = $a['created_at'] ?? '';
|
||||
$bDate = $b['created_at'] ?? '';
|
||||
if ($aDate !== $bDate) return strcmp($bDate, $aDate);
|
||||
return ($b['id'] ?? 0) - ($a['id'] ?? 0);
|
||||
});
|
||||
|
||||
json_response($learnings);
|
||||
}
|
||||
|
||||
require_auth();
|
||||
|
||||
// =====================================================
|
||||
// POST: 새 학습 일지 작성
|
||||
// =====================================================
|
||||
if ($method === 'POST') {
|
||||
$input = get_json_input();
|
||||
|
||||
$title = trim($input['title'] ?? '');
|
||||
$content = trim($input['content'] ?? '');
|
||||
$categoryId = intval($input['category_id'] ?? 0);
|
||||
|
||||
if (empty($title) || empty($content)) {
|
||||
json_response(['error' => '제목과 내용은 필수입니다'], 400);
|
||||
}
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
json_response(['error' => '카테고리를 선택해주세요'], 400);
|
||||
}
|
||||
|
||||
// 카테고리가 서브 카테고리(parent_id 있음)인지 확인 - 글은 서브 카테고리에만 속해야 함
|
||||
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
|
||||
$foundCat = null;
|
||||
foreach ($categories as $c) {
|
||||
if (($c['id'] ?? 0) === $categoryId) {
|
||||
$foundCat = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$foundCat) {
|
||||
json_response(['error' => '카테고리를 찾을 수 없습니다'], 400);
|
||||
}
|
||||
if (empty($foundCat['parent_id'])) {
|
||||
json_response(['error' => '글은 서브 카테고리에만 작성할 수 있습니다 (예: Unity > 학습)'], 400);
|
||||
}
|
||||
|
||||
// 태그 처리
|
||||
$tags = [];
|
||||
if (isset($input['tags'])) {
|
||||
if (is_array($input['tags'])) {
|
||||
$tags = array_values(array_filter(
|
||||
array_map('trim', $input['tags']),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
} elseif (is_string($input['tags'])) {
|
||||
$tags = array_values(array_filter(
|
||||
array_map('trim', explode(',', $input['tags'])),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$learnings = read_json_safe(LEARNING_FILE) ?? [];
|
||||
|
||||
$maxId = 0;
|
||||
foreach ($learnings as $l) {
|
||||
if (($l['id'] ?? 0) > $maxId) $maxId = $l['id'];
|
||||
}
|
||||
|
||||
$createdAt = trim($input['created_at'] ?? '');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) {
|
||||
$createdAt = date('Y-m-d');
|
||||
}
|
||||
|
||||
$newLearning = [
|
||||
'id' => $maxId + 1,
|
||||
'title' => $title,
|
||||
'category_id' => $categoryId,
|
||||
'tags' => $tags,
|
||||
'content' => $content,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => date('Y-m-d')
|
||||
];
|
||||
|
||||
$learnings[] = $newLearning;
|
||||
|
||||
if (write_json_safe(LEARNING_FILE, $learnings)) {
|
||||
json_response(['success' => true, 'learning' => $newLearning]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// PUT: 학습 일지 수정
|
||||
// =====================================================
|
||||
if ($method === 'PUT') {
|
||||
$input = get_json_input();
|
||||
$id = intval($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$learnings = read_json_safe(LEARNING_FILE) ?? [];
|
||||
$found = false;
|
||||
|
||||
foreach ($learnings as $key => $l) {
|
||||
if ($l['id'] === $id) {
|
||||
// is_learning 필드는 제거
|
||||
unset($learnings[$key]['is_learning']);
|
||||
|
||||
if (isset($input['title'])) {
|
||||
$title = trim($input['title']);
|
||||
if ($title === '') json_response(['error' => '제목은 비울 수 없습니다'], 400);
|
||||
$learnings[$key]['title'] = $title;
|
||||
}
|
||||
if (isset($input['content'])) {
|
||||
$content = trim($input['content']);
|
||||
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
|
||||
$learnings[$key]['content'] = $content;
|
||||
}
|
||||
if (isset($input['category_id'])) {
|
||||
$catId = intval($input['category_id']);
|
||||
if ($catId > 0) {
|
||||
// 서브 카테고리 검증
|
||||
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
|
||||
$foundCat = null;
|
||||
foreach ($categories as $c) {
|
||||
if (($c['id'] ?? 0) === $catId) { $foundCat = $c; break; }
|
||||
}
|
||||
if ($foundCat && empty($foundCat['parent_id'])) {
|
||||
json_response(['error' => '글은 서브 카테고리에만 속할 수 있습니다'], 400);
|
||||
}
|
||||
$learnings[$key]['category_id'] = $catId;
|
||||
}
|
||||
}
|
||||
if (isset($input['tags'])) {
|
||||
$tags = is_array($input['tags'])
|
||||
? array_values(array_filter(array_map('trim', $input['tags']), fn($v) => $v !== ''))
|
||||
: (is_string($input['tags'])
|
||||
? array_values(array_filter(array_map('trim', explode(',', $input['tags'])), fn($v) => $v !== ''))
|
||||
: []);
|
||||
$learnings[$key]['tags'] = $tags;
|
||||
}
|
||||
if (isset($input['created_at'])) {
|
||||
$createdAt = trim($input['created_at']);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) {
|
||||
$learnings[$key]['created_at'] = $createdAt;
|
||||
}
|
||||
}
|
||||
$learnings[$key]['updated_at'] = date('Y-m-d');
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
json_response(['error' => '글을 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(LEARNING_FILE, $learnings)) {
|
||||
json_response(['success' => true]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DELETE: 학습 일지 삭제
|
||||
// =====================================================
|
||||
if ($method === 'DELETE') {
|
||||
$id = intval($_GET['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$learnings = read_json_safe(LEARNING_FILE) ?? [];
|
||||
$filtered = array_values(array_filter($learnings, function($l) use ($id) {
|
||||
return ($l['id'] ?? 0) !== $id;
|
||||
}));
|
||||
|
||||
if (count($filtered) === count($learnings)) {
|
||||
json_response(['error' => '글을 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(LEARNING_FILE, $filtered)) {
|
||||
json_response(['success' => true]);
|
||||
} else {
|
||||
json_response(['error' => '삭제에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['error' => 'Method not allowed'], 405);
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// =====================================================
|
||||
// 헬퍼: 기존 퍼센트 기반 스킬을 카테고리 기반으로 마이그레이션
|
||||
// =====================================================
|
||||
function migrate_skills($profile) {
|
||||
if (!isset($profile['skills']) || !is_array($profile['skills'])) {
|
||||
$profile['skills'] = [];
|
||||
return $profile;
|
||||
}
|
||||
|
||||
// 이미 카테고리 형식인지 확인 (첫 항목에 'items' 있으면 새 형식)
|
||||
$first = $profile['skills'][0] ?? null;
|
||||
if ($first && isset($first['category']) && isset($first['items'])) {
|
||||
return $profile; // 이미 새 형식
|
||||
}
|
||||
|
||||
// 기존 형식 (name + level) → 카테고리 형식으로 변환
|
||||
if ($first && isset($first['name']) && isset($first['level'])) {
|
||||
$migrated = [
|
||||
[
|
||||
'category' => '기술',
|
||||
'items' => array_map(fn($s) => $s['name'] ?? '', $profile['skills'])
|
||||
]
|
||||
];
|
||||
$profile['skills'] = $migrated;
|
||||
}
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GET: 프로필 조회 (인증 불필요)
|
||||
// =====================================================
|
||||
if ($method === 'GET') {
|
||||
$profile = read_json_safe(PROFILE_FILE);
|
||||
if ($profile === null) {
|
||||
json_response(['error' => '프로필을 읽을 수 없습니다'], 500);
|
||||
}
|
||||
$profile = migrate_skills($profile);
|
||||
json_response($profile);
|
||||
}
|
||||
|
||||
// 이하 수정은 인증 필요
|
||||
require_auth();
|
||||
|
||||
// =====================================================
|
||||
// PUT: 프로필 전체 업데이트
|
||||
// =====================================================
|
||||
if ($method === 'PUT') {
|
||||
$input = get_json_input();
|
||||
|
||||
if (!is_array($input)) {
|
||||
json_response(['error' => '잘못된 데이터 형식'], 400);
|
||||
}
|
||||
|
||||
$current = read_json_safe(PROFILE_FILE) ?? [];
|
||||
|
||||
$updated = array_merge($current, [
|
||||
'name' => trim($input['name'] ?? $current['name'] ?? ''),
|
||||
'title' => trim($input['title'] ?? $current['title'] ?? ''),
|
||||
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
|
||||
'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''),
|
||||
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
|
||||
'location' => trim($input['location'] ?? $current['location'] ?? ''),
|
||||
'email' => trim($input['email'] ?? $current['email'] ?? ''),
|
||||
]);
|
||||
|
||||
// 카테고리화된 스킬 처리
|
||||
if (isset($input['skills']) && is_array($input['skills'])) {
|
||||
$cleanSkills = [];
|
||||
foreach ($input['skills'] as $cat) {
|
||||
if (!isset($cat['category']) || !isset($cat['items'])) continue;
|
||||
$catName = trim($cat['category']);
|
||||
if ($catName === '') continue;
|
||||
|
||||
$items = is_array($cat['items'])
|
||||
? array_values(array_filter(array_map('trim', $cat['items']), fn($v) => $v !== ''))
|
||||
: [];
|
||||
|
||||
if (count($items) > 0) {
|
||||
$cleanSkills[] = [
|
||||
'category' => $catName,
|
||||
'items' => $items
|
||||
];
|
||||
}
|
||||
}
|
||||
$updated['skills'] = $cleanSkills;
|
||||
}
|
||||
|
||||
if (isset($input['timeline']) && is_array($input['timeline'])) {
|
||||
$updated['timeline'] = array_values(array_filter($input['timeline'], function($t) {
|
||||
return !empty($t['title']);
|
||||
}));
|
||||
}
|
||||
|
||||
if (isset($input['social']) && is_array($input['social'])) {
|
||||
$updated['social'] = array_merge($current['social'] ?? [], $input['social']);
|
||||
}
|
||||
|
||||
if (write_json_safe(PROFILE_FILE, $updated)) {
|
||||
json_response(['success' => true, 'profile' => $updated]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['error' => 'Method not allowed'], 405);
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// =====================================================
|
||||
// 헬퍼: images 배열 정규화
|
||||
// =====================================================
|
||||
function normalize_images($input) {
|
||||
if (isset($input['images']) && is_array($input['images'])) {
|
||||
$images = array_values(array_filter(
|
||||
array_map('trim', $input['images']),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
return $images;
|
||||
}
|
||||
if (isset($input['image']) && trim($input['image']) !== '') {
|
||||
return [trim($input['image'])];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalize_stack($input) {
|
||||
if (isset($input['stack']) && is_array($input['stack'])) {
|
||||
return array_values(array_filter(
|
||||
array_map('trim', $input['stack']),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
}
|
||||
if (isset($input['stack']) && is_string($input['stack'])) {
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(',', $input['stack'])),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 기존 프로젝트 데이터를 새 형식으로 마이그레이션
|
||||
function migrate_project_data($project) {
|
||||
if (!isset($project['images']) || !is_array($project['images'])) {
|
||||
$project['images'] = [];
|
||||
if (!empty($project['image'])) {
|
||||
$project['images'][] = $project['image'];
|
||||
}
|
||||
}
|
||||
$project['image'] = $project['images'][0] ?? '';
|
||||
|
||||
// 새 필드들 기본값
|
||||
if (!isset($project['stack']) || !is_array($project['stack'])) $project['stack'] = [];
|
||||
if (!isset($project['period_start'])) $project['period_start'] = '';
|
||||
if (!isset($project['period_end'])) $project['period_end'] = '';
|
||||
if (!isset($project['video_url'])) $project['video_url'] = '';
|
||||
|
||||
// 기존 demo_url 필드는 제거 (마이그레이션)
|
||||
unset($project['demo_url']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GET: 프로젝트 목록 조회
|
||||
// =====================================================
|
||||
if ($method === 'GET') {
|
||||
$projects = read_json_safe(PROJECTS_FILE);
|
||||
if ($projects === null) {
|
||||
json_response(['error' => '데이터를 읽을 수 없습니다'], 500);
|
||||
}
|
||||
|
||||
$projects = array_map('migrate_project_data', $projects);
|
||||
|
||||
usort($projects, function($a, $b) {
|
||||
return ($b['id'] ?? 0) - ($a['id'] ?? 0);
|
||||
});
|
||||
|
||||
json_response($projects);
|
||||
}
|
||||
|
||||
require_auth();
|
||||
|
||||
// =====================================================
|
||||
// POST: 새 프로젝트 추가
|
||||
// =====================================================
|
||||
if ($method === 'POST') {
|
||||
$input = get_json_input();
|
||||
|
||||
$title = trim($input['title'] ?? '');
|
||||
$label = trim($input['label'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
|
||||
if (empty($title) || empty($label) || empty($description)) {
|
||||
json_response(['error' => '제목, 라벨, 설명은 필수입니다'], 400);
|
||||
}
|
||||
|
||||
$projects = read_json_safe(PROJECTS_FILE) ?? [];
|
||||
|
||||
$maxId = 0;
|
||||
foreach ($projects as $p) {
|
||||
if (($p['id'] ?? 0) > $maxId) $maxId = $p['id'];
|
||||
}
|
||||
|
||||
$images = normalize_images($input);
|
||||
$stack = normalize_stack($input);
|
||||
|
||||
$newProject = [
|
||||
'id' => $maxId + 1,
|
||||
'title' => $title,
|
||||
'label' => $label,
|
||||
'description' => $description,
|
||||
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
|
||||
'images' => $images,
|
||||
'image' => $images[0] ?? '',
|
||||
'link' => trim($input['link'] ?? ''),
|
||||
'stack' => $stack,
|
||||
'period_start' => trim($input['period_start'] ?? ''),
|
||||
'period_end' => trim($input['period_end'] ?? ''),
|
||||
'video_url' => trim($input['video_url'] ?? ''),
|
||||
'created_at' => date('Y-m-d')
|
||||
];
|
||||
|
||||
$projects[] = $newProject;
|
||||
|
||||
if (write_json_safe(PROJECTS_FILE, $projects)) {
|
||||
json_response(['success' => true, 'project' => $newProject]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// PUT: 프로젝트 수정
|
||||
// =====================================================
|
||||
if ($method === 'PUT') {
|
||||
$input = get_json_input();
|
||||
$id = intval($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$projects = read_json_safe(PROJECTS_FILE) ?? [];
|
||||
$found = false;
|
||||
|
||||
foreach ($projects as $key => $project) {
|
||||
if ($project['id'] === $id) {
|
||||
$projects[$key]['title'] = trim($input['title'] ?? $project['title']);
|
||||
$projects[$key]['label'] = trim($input['label'] ?? $project['label']);
|
||||
$projects[$key]['description'] = trim($input['description'] ?? $project['description']);
|
||||
$projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']);
|
||||
$projects[$key]['link'] = trim($input['link'] ?? $project['link']);
|
||||
|
||||
if (isset($input['images']) || isset($input['image'])) {
|
||||
$images = normalize_images($input);
|
||||
$projects[$key]['images'] = $images;
|
||||
$projects[$key]['image'] = $images[0] ?? '';
|
||||
}
|
||||
|
||||
if (isset($input['stack'])) {
|
||||
$projects[$key]['stack'] = normalize_stack($input);
|
||||
}
|
||||
|
||||
if (isset($input['period_start'])) {
|
||||
$projects[$key]['period_start'] = trim($input['period_start']);
|
||||
}
|
||||
if (isset($input['period_end'])) {
|
||||
$projects[$key]['period_end'] = trim($input['period_end']);
|
||||
}
|
||||
if (isset($input['video_url'])) {
|
||||
$projects[$key]['video_url'] = trim($input['video_url']);
|
||||
}
|
||||
|
||||
// 기존 demo_url 필드 제거
|
||||
unset($projects[$key]['demo_url']);
|
||||
|
||||
$projects[$key]['updated_at'] = date('Y-m-d');
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(PROJECTS_FILE, $projects)) {
|
||||
json_response(['success' => true]);
|
||||
} else {
|
||||
json_response(['error' => '저장에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DELETE: 프로젝트 삭제
|
||||
// =====================================================
|
||||
if ($method === 'DELETE') {
|
||||
$id = intval($_GET['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_response(['error' => '유효하지 않은 ID'], 400);
|
||||
}
|
||||
|
||||
$projects = read_json_safe(PROJECTS_FILE) ?? [];
|
||||
$filtered = array_values(array_filter($projects, function($p) use ($id) {
|
||||
return ($p['id'] ?? 0) !== $id;
|
||||
}));
|
||||
|
||||
if (count($filtered) === count($projects)) {
|
||||
json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404);
|
||||
}
|
||||
|
||||
if (write_json_safe(PROJECTS_FILE, $filtered)) {
|
||||
json_response(['success' => true]);
|
||||
} else {
|
||||
json_response(['error' => '삭제에 실패했습니다'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['error' => 'Method not allowed'], 405);
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
set_json_headers();
|
||||
require_auth();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'POST만 허용됩니다'], 405);
|
||||
}
|
||||
|
||||
if (!isset($_FILES['file']) && !isset($_FILES['image'])) {
|
||||
json_response(['error' => '파일 업로드 실패'], 400);
|
||||
}
|
||||
|
||||
// 'file' 또는 'image' 둘 다 받기 (호환성)
|
||||
$file = $_FILES['file'] ?? $_FILES['image'];
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
json_response(['error' => '파일 업로드 실패 (코드: ' . $file['error'] . ')'], 400);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 파일 사이즈 조정
|
||||
// 이미지 5MB / 동영상 100MB / 일반 파일 50MB
|
||||
// 단위: byte (1MB = 1024 * 1024)
|
||||
// 제한 없음으로 하려면 PHP_INT_MAX로 설정
|
||||
// 단, Synology PHP의 upload_max_filesize / post_max_size 설정도 함께 조정 필요
|
||||
// =====================================================
|
||||
$MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 이미지: 5 MB
|
||||
$MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 동영상: 100 MB
|
||||
$MAX_FILE_SIZE = 50 * 1024 * 1024; // 일반 파일: 50 MB
|
||||
// =====================================================
|
||||
|
||||
// 허용 MIME 타입과 확장자
|
||||
$image_types = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp'
|
||||
];
|
||||
$video_types = [
|
||||
'video/mp4' => 'mp4',
|
||||
'video/webm' => 'webm',
|
||||
'video/ogg' => 'ogv',
|
||||
'video/quicktime' => 'mov'
|
||||
];
|
||||
// 일반 파일 허용 목록 (MIME → 확장자)
|
||||
$file_types = [
|
||||
'application/pdf' => 'pdf',
|
||||
'application/zip' => 'zip',
|
||||
'application/x-zip-compressed' => 'zip',
|
||||
'application/x-7z-compressed' => '7z',
|
||||
'application/x-rar-compressed' => 'rar',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-powerpoint' => 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
||||
'text/plain' => 'txt',
|
||||
'text/markdown' => 'md',
|
||||
'text/csv' => 'csv',
|
||||
'application/json' => 'json',
|
||||
];
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
|
||||
$is_image = isset($image_types[$mime]);
|
||||
$is_video = isset($video_types[$mime]);
|
||||
$is_file = !$is_image && !$is_video && isset($file_types[$mime]);
|
||||
|
||||
if (!$is_image && !$is_video && !$is_file) {
|
||||
json_response(['error' => '허용되지 않는 파일 형식입니다. (이미지/동영상/PDF/ZIP/문서 등)'], 400);
|
||||
}
|
||||
|
||||
// 파일 크기 검증
|
||||
if ($is_image && $file['size'] > $MAX_IMAGE_SIZE) {
|
||||
$mb = round($MAX_IMAGE_SIZE / 1024 / 1024, 1);
|
||||
json_response(['error' => "이미지 파일은 {$mb}MB 이하여야 합니다"], 400);
|
||||
}
|
||||
if ($is_video && $file['size'] > $MAX_VIDEO_SIZE) {
|
||||
$mb = round($MAX_VIDEO_SIZE / 1024 / 1024, 1);
|
||||
json_response(['error' => "동영상 파일은 {$mb}MB 이하여야 합니다"], 400);
|
||||
}
|
||||
if ($is_file && $file['size'] > $MAX_FILE_SIZE) {
|
||||
$mb = round($MAX_FILE_SIZE / 1024 / 1024, 1);
|
||||
json_response(['error' => "파일은 {$mb}MB 이하여야 합니다"], 400);
|
||||
}
|
||||
|
||||
if ($is_image) {
|
||||
$ext = $image_types[$mime];
|
||||
$kind = 'image';
|
||||
$prefix = 'img_';
|
||||
} elseif ($is_video) {
|
||||
$ext = $video_types[$mime];
|
||||
$kind = 'video';
|
||||
$prefix = 'vid_';
|
||||
} else {
|
||||
$ext = $file_types[$mime];
|
||||
$kind = 'file';
|
||||
$prefix = 'file_';
|
||||
// 일반 파일은 원본 파일명 일부를 살림 (정리 후)
|
||||
$origName = pathinfo($file['name'], PATHINFO_FILENAME);
|
||||
$origName = preg_replace('/[^\p{L}\p{N}\-_]/u', '_', $origName);
|
||||
$origName = mb_substr($origName, 0, 30, 'UTF-8');
|
||||
$prefix = $origName . '_';
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 폴더명 생성
|
||||
// =====================================================
|
||||
function sanitize_folder_name($title) {
|
||||
$title = trim($title);
|
||||
if ($title === '') return '';
|
||||
|
||||
// 위험한 문자 제거
|
||||
$title = preg_replace('/[\/\\\\\:\*\?"<>\|]/u', '', $title);
|
||||
$title = preg_replace('/\.\.+/u', '', $title);
|
||||
|
||||
// 공백을 하이픈으로
|
||||
$title = preg_replace('/\s+/u', '-', $title);
|
||||
|
||||
// 영문/숫자/한글/하이픈/언더스코어 외 모두 제거
|
||||
$title = preg_replace('/[^\p{L}\p{N}\-_]/u', '', $title);
|
||||
|
||||
$title = trim($title, '-_');
|
||||
|
||||
if (mb_strlen($title, 'UTF-8') > 50) {
|
||||
$title = mb_substr($title, 0, 50, 'UTF-8');
|
||||
}
|
||||
|
||||
return $title;
|
||||
}
|
||||
|
||||
$projectTitle = isset($_POST['project_title']) ? trim($_POST['project_title']) : '';
|
||||
$folderName = sanitize_folder_name($projectTitle);
|
||||
|
||||
if ($folderName === '') {
|
||||
$folderName = '_untitled';
|
||||
}
|
||||
|
||||
$uploadDir = UPLOADS_DIR . '/' . $folderName;
|
||||
|
||||
if (!is_dir(UPLOADS_DIR)) {
|
||||
@mkdir(UPLOADS_DIR, 0775, true);
|
||||
}
|
||||
if (!is_dir($uploadDir)) {
|
||||
if (!@mkdir($uploadDir, 0775, true)) {
|
||||
json_response(['error' => '폴더 생성 실패. uploads 폴더에 쓰기 권한을 확인해주세요.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 안전한 파일명 생성
|
||||
$filename = uniqid($prefix, true) . '.' . $ext;
|
||||
$target_path = $uploadDir . '/' . $filename;
|
||||
|
||||
// =====================================================
|
||||
// 이미지: WebP 변환 시도 (GD 지원 시)
|
||||
// 동영상/일반 파일은 원본 그대로 저장
|
||||
// =====================================================
|
||||
function try_save_as_webp(string $tmp, string $dest_dir, string $base_name, string $mime, int $quality = 82): string|false {
|
||||
if (!function_exists('imagewebp')) return false;
|
||||
|
||||
$src = match ($mime) {
|
||||
'image/jpeg' => @imagecreatefromjpeg($tmp),
|
||||
'image/png' => @imagecreatefrompng($tmp),
|
||||
'image/gif' => @imagecreatefromgif($tmp),
|
||||
'image/webp' => @imagecreatefromwebp($tmp),
|
||||
default => false,
|
||||
};
|
||||
if (!$src) return false;
|
||||
|
||||
// PNG 투명도 보존
|
||||
if ($mime === 'image/png') {
|
||||
imagepalettetotruecolor($src);
|
||||
imagealphablending($src, true);
|
||||
imagesavealpha($src, true);
|
||||
}
|
||||
|
||||
// 가로 1200px 초과 시 리사이즈
|
||||
$ow = imagesx($src);
|
||||
$oh = imagesy($src);
|
||||
if ($ow > 1200) {
|
||||
$nw = 1200;
|
||||
$nh = (int)round($oh * (1200 / $ow));
|
||||
$dst = imagecreatetruecolor($nw, $nh);
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$t = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $nw, $nh, $t);
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh);
|
||||
imagedestroy($src);
|
||||
$src = $dst;
|
||||
}
|
||||
|
||||
$dest_path = $dest_dir . '/' . $base_name . '.webp';
|
||||
$ok = imagewebp($src, $dest_path, $quality);
|
||||
imagedestroy($src);
|
||||
return $ok ? $dest_path : false;
|
||||
}
|
||||
|
||||
if ($is_image) {
|
||||
$base_name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$webp_path = try_save_as_webp($file['tmp_name'], $uploadDir, $base_name, $mime);
|
||||
if ($webp_path !== false) {
|
||||
$filename = $base_name . '.webp';
|
||||
$target_path = $webp_path;
|
||||
$saved = true;
|
||||
} else {
|
||||
$saved = move_uploaded_file($file['tmp_name'], $target_path);
|
||||
}
|
||||
} else {
|
||||
$saved = move_uploaded_file($file['tmp_name'], $target_path);
|
||||
}
|
||||
|
||||
if ($saved) {
|
||||
json_response([
|
||||
'success' => true,
|
||||
'url' => 'uploads/' . $folderName . '/' . $filename,
|
||||
'filename' => $filename,
|
||||
'original_name' => $file['name'],
|
||||
'folder' => $folderName,
|
||||
'kind' => $kind
|
||||
]);
|
||||
} else {
|
||||
$err = error_get_last();
|
||||
json_response(['error' => '파일 저장 실패', 'detail' => $err, 'target' => $target_path, 'tmp' => $file['tmp_name'], 'tmp_exists' => file_exists($file['tmp_name'])], 500);
|
||||
}
|
||||
Reference in New Issue
Block a user