Initial profile site commit

This commit is contained in:
2026-05-31 21:05:59 +09:00
commit db81f0e4a4
49 changed files with 18829 additions and 0 deletions
+60
View File
@@ -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);
+210
View File
@@ -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
View File
@@ -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;
}
+100
View File
@@ -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
]);
+58
View File
@@ -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;
});
+249
View File
@@ -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);
View File
+114
View File
@@ -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);
+221
View File
@@ -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
View File
@@ -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);
}