Harden admin security controls
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
.strideterm/
|
.strideterm/
|
||||||
|
api/config.local.php
|
||||||
|
|||||||
@@ -42,24 +42,36 @@ portfolio/
|
|||||||
|
|
||||||
File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요.
|
File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요.
|
||||||
|
|
||||||
### 4. 비밀번호 설정 (⚠️ 매우 중요)
|
### 4. 관리자 비밀번호와 보안 설정
|
||||||
|
|
||||||
**(A) 해시 생성**
|
실제 관리자 비밀번호 해시는 저장소에 커밋하지 않습니다. 운영 서버에서는 다음 둘 중 하나로 설정하세요.
|
||||||
브라우저에서 접속:
|
|
||||||
|
**(A) api/config.local.php 사용 권장**
|
||||||
|
|
||||||
|
1. `api/config.local.example.php`를 `api/config.local.php`로 복사합니다.
|
||||||
|
2. 브라우저에서 다음 주소에 접속해 원하는 비밀번호의 해시를 생성합니다.
|
||||||
```
|
```
|
||||||
http://your-nas/portfolio/generate_password.php
|
http://your-nas/portfolio/generate_password.php
|
||||||
```
|
```
|
||||||
원하는 비밀번호를 입력하면 해시가 출력됩니다.
|
3. 생성된 해시를 `api/config.local.php`의 `ADMIN_PASSWORD_HASH` 값으로 넣습니다.
|
||||||
|
4. `api/config.local.php`는 `.gitignore`에 포함되어 있으므로 저장소에 올리지 않습니다.
|
||||||
|
|
||||||
**(B) config.php 수정**
|
**(B) 환경변수 사용**
|
||||||
`api/config.php` 파일을 열어 아래 줄을 찾고:
|
|
||||||
```php
|
서버 환경변수 `ADMIN_PASSWORD_HASH`에 `password_hash()`로 만든 해시를 설정해도 됩니다.
|
||||||
define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...');
|
|
||||||
|
```bash
|
||||||
|
ADMIN_PASSWORD_HASH='$2y$10$replace_with_your_generated_hash'
|
||||||
```
|
```
|
||||||
방금 생성된 해시로 교체합니다.
|
|
||||||
|
|
||||||
**(C) generate_password.php 삭제**
|
`api/config.php`에는 기본 관리자 비밀번호나 실제 해시가 들어 있지 않아야 합니다. 설정이 없으면 관리자 로그인은 실패합니다.
|
||||||
보안을 위해 반드시 삭제하세요!
|
|
||||||
|
**추가 운영 보안**
|
||||||
|
|
||||||
|
- 로그인 후 상태 변경 API는 CSRF 토큰을 요구합니다. 프론트엔드의 `fetch` 흐름은 로그인/세션 확인 응답에서 받은 토큰을 자동으로 보냅니다.
|
||||||
|
- 업로드 실패 응답에는 서버 내부 경로, 임시 경로, 저장 대상 경로가 노출되지 않습니다. 상세 오류는 서버 로그에서만 확인합니다.
|
||||||
|
- URL 입력은 `http`, `https`, `uploads/` 상대 경로만 허용합니다. CSS 색상 값은 hex 색상 allowlist로 제한합니다.
|
||||||
|
- 보안을 위해 `generate_password.php`는 설정 완료 후 운영 서버에서 삭제하거나 접근을 차단하세요.
|
||||||
|
|
||||||
### 5. 접속
|
### 5. 접속
|
||||||
- 메인: `http://your-nas/portfolio/`
|
- 메인: `http://your-nas/portfolio/`
|
||||||
@@ -82,7 +94,8 @@ define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...');
|
|||||||
## 🔒 보안 체크리스트
|
## 🔒 보안 체크리스트
|
||||||
|
|
||||||
- [ ] `generate_password.php` 삭제했는가?
|
- [ ] `generate_password.php` 삭제했는가?
|
||||||
- [ ] `config.php`의 ADMIN_PASSWORD_HASH를 실제 해시로 교체했는가?
|
- [ ] `api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 실제 관리자 해시가 설정되어 있는가?
|
||||||
|
- [ ] `api/config.local.php`가 저장소에 커밋되지 않는가?
|
||||||
- [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt)
|
- [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt)
|
||||||
- [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합
|
- [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합
|
||||||
- [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인
|
- [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인
|
||||||
@@ -107,7 +120,7 @@ git push -u origin main
|
|||||||
→ `data/` 폴더의 쓰기 권한을 확인하세요.
|
→ `data/` 폴더의 쓰기 권한을 확인하세요.
|
||||||
|
|
||||||
**로그인이 안 됨**
|
**로그인이 안 됨**
|
||||||
→ `config.php`의 ADMIN_PASSWORD_HASH가 올바르게 교체되었는지 확인하세요.
|
→ `api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 올바른 해시가 설정되어 있는지 확인하세요.
|
||||||
|
|
||||||
**.htaccess가 동작하지 않음**
|
**.htaccess가 동작하지 않음**
|
||||||
→ Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요.
|
→ Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요.
|
||||||
|
|||||||
+13
-3
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -21,12 +21,16 @@ if ($method === 'POST' && $action === 'login') {
|
|||||||
// 무차별 대입 방지 - 간단한 딜레이
|
// 무차별 대입 방지 - 간단한 딜레이
|
||||||
usleep(500000); // 0.5초
|
usleep(500000); // 0.5초
|
||||||
|
|
||||||
|
if (ADMIN_PASSWORD_HASH === '') {
|
||||||
|
json_response(['error' => 'Admin password is not configured'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||||
// 세션 고정 공격 방지
|
// 세션 고정 공격 방지
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['authenticated'] = true;
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['login_time'] = time();
|
$_SESSION['login_time'] = time();
|
||||||
json_response(['success' => true, 'message' => '로그인 성공']);
|
json_response(['success' => true, 'message' => '로그인 성공', 'csrf_token' => ensure_csrf_token()]);
|
||||||
} else {
|
} else {
|
||||||
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
|
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,8 @@ if ($method === 'POST' && $action === 'login') {
|
|||||||
// 로그아웃
|
// 로그아웃
|
||||||
// =====================================================
|
// =====================================================
|
||||||
if ($method === 'POST' && $action === 'logout') {
|
if ($method === 'POST' && $action === 'logout') {
|
||||||
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
session_destroy();
|
session_destroy();
|
||||||
json_response(['success' => true]);
|
json_response(['success' => true]);
|
||||||
}
|
}
|
||||||
@@ -54,7 +60,11 @@ if ($method === 'GET' && $action === 'check') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
json_response(['authenticated' => $authenticated]);
|
$response = ['authenticated' => $authenticated];
|
||||||
|
if ($authenticated) {
|
||||||
|
$response['csrf_token'] = ensure_csrf_token();
|
||||||
|
}
|
||||||
|
json_response($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
json_response(['error' => 'Invalid action'], 400);
|
json_response(['error' => 'Invalid action'], 400);
|
||||||
|
|||||||
+5
-4
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
|
|
||||||
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
|
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
|
||||||
@@ -12,7 +12,7 @@ $method = $_SERVER['REQUEST_METHOD'];
|
|||||||
function normalize_category($cat) {
|
function normalize_category($cat) {
|
||||||
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
|
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
|
||||||
if (!isset($cat['order'])) $cat['order'] = 0;
|
if (!isset($cat['order'])) $cat['order'] = 0;
|
||||||
if (!isset($cat['color'])) $cat['color'] = '#00f2ff';
|
$cat['color'] = clean_css_color($cat['color'] ?? '#00f2ff');
|
||||||
return $cat;
|
return $cat;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ if ($method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// POST: 카테고리 추가
|
// POST: 카테고리 추가
|
||||||
@@ -42,7 +43,7 @@ require_auth();
|
|||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
$input = get_json_input();
|
$input = get_json_input();
|
||||||
$name = trim($input['name'] ?? '');
|
$name = trim($input['name'] ?? '');
|
||||||
$color = trim($input['color'] ?? '#00f2ff');
|
$color = clean_css_color($input['color'] ?? '#00f2ff');
|
||||||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
|
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
|
||||||
? intval($input['parent_id'])
|
? intval($input['parent_id'])
|
||||||
: null;
|
: null;
|
||||||
@@ -115,7 +116,7 @@ if ($method === 'PUT') {
|
|||||||
foreach ($categories as $key => $cat) {
|
foreach ($categories as $key => $cat) {
|
||||||
if ($cat['id'] === $id) {
|
if ($cat['id'] === $id) {
|
||||||
if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']);
|
if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']);
|
||||||
if (isset($input['color'])) $categories[$key]['color'] = trim($input['color']);
|
if (isset($input['color'])) $categories[$key]['color'] = clean_css_color($input['color']);
|
||||||
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
|
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
|
||||||
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
|
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
|
||||||
if (array_key_exists('parent_id', $input)) {
|
if (array_key_exists('parent_id', $input)) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
// Copy this file to api/config.local.php on the server and replace the value.
|
||||||
|
// Generate a hash with generate_password.php or:
|
||||||
|
// php -r "echo password_hash('your-strong-password', PASSWORD_DEFAULT);"
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$replace_with_your_generated_hash');
|
||||||
+102
-38
@@ -1,39 +1,57 @@
|
|||||||
<?php
|
<?php
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// 설정 파일
|
// Runtime configuration
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// ⚠️ 중요: 처음 사용 시 아래 ADMIN_PASSWORD_HASH를 변경하세요
|
// ADMIN_PASSWORD_HASH is loaded from api/config.local.php or the environment.
|
||||||
// 비밀번호 해시 생성 방법:
|
// Do not commit real password hashes or other secrets to the repository.
|
||||||
// 브라우저에서 generate_password.php 접속 후 원하는 비번 입력
|
|
||||||
// 또는 터미널에서: php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
// 기본 비밀번호: "admin1234" (반드시 변경하세요!)
|
$localConfig = __DIR__ . '/config.local.php';
|
||||||
define('ADMIN_PASSWORD_HASH', '$2y$10$Wj/5fxQX90AlvyVPBfE0te2aUbysSBlE/Umm7EluG880rqcRUlHGm');
|
if (is_file($localConfig)) {
|
||||||
|
require_once $localConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 파일 경로
|
if (!defined('ADMIN_PASSWORD_HASH')) {
|
||||||
|
$adminPasswordHash = getenv('ADMIN_PASSWORD_HASH');
|
||||||
|
define('ADMIN_PASSWORD_HASH', is_string($adminPasswordHash) ? trim($adminPasswordHash) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data paths
|
||||||
define('DATA_DIR', __DIR__ . '/../data');
|
define('DATA_DIR', __DIR__ . '/../data');
|
||||||
define('UPLOADS_DIR', __DIR__ . '/../uploads');
|
define('UPLOADS_DIR', __DIR__ . '/../uploads');
|
||||||
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
|
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
|
||||||
define('PROFILE_FILE', DATA_DIR . '/profile.json');
|
define('PROFILE_FILE', DATA_DIR . '/profile.json');
|
||||||
|
|
||||||
// 세션 설정
|
// Session settings
|
||||||
define('SESSION_LIFETIME', 3600 * 4); // 4시간
|
define('SESSION_LIFETIME', 3600 * 4);
|
||||||
|
|
||||||
|
function start_secure_session() {
|
||||||
|
if (session_status() !== PHP_SESSION_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
// CORS 및 JSON 헤더
|
|
||||||
function set_json_headers() {
|
function set_json_headers() {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 파일을 락 걸고 읽기
|
|
||||||
function read_json_safe($filepath) {
|
function read_json_safe($filepath) {
|
||||||
if (!file_exists($filepath)) {
|
if (!file_exists($filepath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$fp = fopen($filepath, 'r');
|
$fp = fopen($filepath, 'r');
|
||||||
if (!$fp) return null;
|
if (!$fp) return null;
|
||||||
|
|
||||||
if (flock($fp, LOCK_SH)) {
|
if (flock($fp, LOCK_SH)) {
|
||||||
$content = stream_get_contents($fp);
|
$content = stream_get_contents($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
@@ -44,41 +62,33 @@ function read_json_safe($filepath) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 파일을 락 걸고 쓰기
|
// Write JSON atomically so a crash during write does not leave a 0-byte file.
|
||||||
function write_json_safe($filepath, $data) {
|
function write_json_safe($filepath, $data) {
|
||||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
if ($json === false) return false;
|
if ($json === false) return false;
|
||||||
|
|
||||||
$fp = fopen($filepath, 'c+');
|
$tmp = $filepath . '.tmp.' . uniqid('', true);
|
||||||
if (!$fp) return false;
|
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||||
|
@unlink($tmp);
|
||||||
if (flock($fp, LOCK_EX)) {
|
return false;
|
||||||
ftruncate($fp, 0);
|
|
||||||
rewind($fp);
|
|
||||||
fwrite($fp, $json);
|
|
||||||
fflush($fp);
|
|
||||||
flock($fp, LOCK_UN);
|
|
||||||
fclose($fp);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
fclose($fp);
|
if (!rename($tmp, $filepath)) {
|
||||||
return false;
|
@unlink($tmp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 체크
|
|
||||||
function require_auth() {
|
function require_auth() {
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
start_secure_session();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션 타임아웃 체크
|
if (isset($_SESSION['login_time']) &&
|
||||||
if (isset($_SESSION['login_time']) &&
|
|
||||||
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
||||||
session_destroy();
|
session_destroy();
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
@@ -87,13 +97,67 @@ function require_auth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 입력 받기
|
function ensure_csrf_token() {
|
||||||
|
start_secure_session();
|
||||||
|
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_csrf() {
|
||||||
|
start_secure_session();
|
||||||
|
$sessionToken = $_SESSION['csrf_token'] ?? '';
|
||||||
|
$requestToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
|
||||||
|
if (!is_string($sessionToken) || $sessionToken === '' ||
|
||||||
|
!is_string($requestToken) || !hash_equals($sessionToken, $requestToken)) {
|
||||||
|
json_response(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function get_json_input() {
|
function get_json_input() {
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
return json_decode($input, true);
|
return json_decode($input, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답 헬퍼
|
function is_safe_public_url($url) {
|
||||||
|
if (!is_string($url)) return false;
|
||||||
|
$url = trim($url);
|
||||||
|
if ($url === '') return true;
|
||||||
|
|
||||||
|
if (preg_match('#^uploads/[A-Za-z0-9가-힣._~!$&\'()*+,;=:@%/-]+$#u', $url)) {
|
||||||
|
return !str_contains($url, '..') && !preg_match('#(^|/)\.#', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($url);
|
||||||
|
if ($parts === false || empty($parts['scheme'])) return false;
|
||||||
|
return in_array(strtolower($parts['scheme']), ['http', 'https'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_public_url($url) {
|
||||||
|
$url = is_string($url) ? trim($url) : '';
|
||||||
|
return is_safe_public_url($url) ? $url : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_public_urls($urls) {
|
||||||
|
if (!is_array($urls)) return [];
|
||||||
|
$clean = [];
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$url = clean_public_url($url);
|
||||||
|
if ($url !== '') $clean[] = $url;
|
||||||
|
}
|
||||||
|
return array_values(array_unique($clean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_css_color($value, $fallback = '#00f2ff') {
|
||||||
|
$value = is_string($value) ? trim($value) : '';
|
||||||
|
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value) || preg_match('/^#[0-9a-fA-F]{3}$/', $value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function json_response($data, $status = 200) {
|
function json_response($data, $status = 200) {
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -1,13 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
|
|
||||||
define('LEARNING_FILE', DATA_DIR . '/learning.json');
|
define('LEARNING_FILE', DATA_DIR . '/learning.json');
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
function clean_learning_content($content) {
|
||||||
|
$content = is_string($content) ? trim($content) : '';
|
||||||
|
$content = preg_replace_callback('/\]\(([^)]+)\)/', function($m) {
|
||||||
|
$url = trim($m[1]);
|
||||||
|
return is_safe_public_url($url) ? '](' . $url . ')' : '](#)';
|
||||||
|
}, $content);
|
||||||
|
$content = preg_replace_callback('/@video(\[[^\]]*\])?\(([^)]+)\)/', function($m) {
|
||||||
|
$attrs = $m[1] ?? '';
|
||||||
|
$url = trim($m[2]);
|
||||||
|
return is_safe_public_url($url) ? '@video' . $attrs . '(' . $url . ')' : '';
|
||||||
|
}, $content);
|
||||||
|
$content = preg_replace_callback('/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/', function($m) {
|
||||||
|
$color = clean_css_color($m[1], '');
|
||||||
|
return $color === '' ? $m[2] : '{color:' . $color . '}' . $m[2] . '{/color}';
|
||||||
|
}, $content);
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
|
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@@ -63,6 +81,7 @@ if ($method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// POST: 새 학습 일지 작성
|
// POST: 새 학습 일지 작성
|
||||||
@@ -71,7 +90,7 @@ if ($method === 'POST') {
|
|||||||
$input = get_json_input();
|
$input = get_json_input();
|
||||||
|
|
||||||
$title = trim($input['title'] ?? '');
|
$title = trim($input['title'] ?? '');
|
||||||
$content = trim($input['content'] ?? '');
|
$content = clean_learning_content($input['content'] ?? '');
|
||||||
$categoryId = intval($input['category_id'] ?? 0);
|
$categoryId = intval($input['category_id'] ?? 0);
|
||||||
|
|
||||||
if (empty($title) || empty($content)) {
|
if (empty($title) || empty($content)) {
|
||||||
@@ -170,7 +189,7 @@ if ($method === 'PUT') {
|
|||||||
$learnings[$key]['title'] = $title;
|
$learnings[$key]['title'] = $title;
|
||||||
}
|
}
|
||||||
if (isset($input['content'])) {
|
if (isset($input['content'])) {
|
||||||
$content = trim($input['content']);
|
$content = clean_learning_content($input['content']);
|
||||||
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
|
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
|
||||||
$learnings[$key]['content'] = $content;
|
$learnings[$key]['content'] = $content;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -49,6 +49,7 @@ if ($method === 'GET') {
|
|||||||
|
|
||||||
// 이하 수정은 인증 필요
|
// 이하 수정은 인증 필요
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// PUT: 프로필 전체 업데이트
|
// PUT: 프로필 전체 업데이트
|
||||||
@@ -66,7 +67,7 @@ if ($method === 'PUT') {
|
|||||||
'name' => trim($input['name'] ?? $current['name'] ?? ''),
|
'name' => trim($input['name'] ?? $current['name'] ?? ''),
|
||||||
'title' => trim($input['title'] ?? $current['title'] ?? ''),
|
'title' => trim($input['title'] ?? $current['title'] ?? ''),
|
||||||
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
|
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
|
||||||
'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''),
|
'avatar' => clean_public_url($input['avatar'] ?? $current['avatar'] ?? ''),
|
||||||
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
|
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
|
||||||
'location' => trim($input['location'] ?? $current['location'] ?? ''),
|
'location' => trim($input['location'] ?? $current['location'] ?? ''),
|
||||||
'email' => trim($input['email'] ?? $current['email'] ?? ''),
|
'email' => trim($input['email'] ?? $current['email'] ?? ''),
|
||||||
@@ -101,7 +102,13 @@ if ($method === 'PUT') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isset($input['social']) && is_array($input['social'])) {
|
if (isset($input['social']) && is_array($input['social'])) {
|
||||||
$updated['social'] = array_merge($current['social'] ?? [], $input['social']);
|
$social = array_merge($current['social'] ?? [], $input['social']);
|
||||||
|
foreach (['github', 'linkedin', 'blog'] as $key) {
|
||||||
|
if (isset($social[$key])) {
|
||||||
|
$social[$key] = clean_public_url($social[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$updated['social'] = $social;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (write_json_safe(PROFILE_FILE, $updated)) {
|
if (write_json_safe(PROFILE_FILE, $updated)) {
|
||||||
|
|||||||
+9
-11
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -11,14 +11,11 @@ $method = $_SERVER['REQUEST_METHOD'];
|
|||||||
// =====================================================
|
// =====================================================
|
||||||
function normalize_images($input) {
|
function normalize_images($input) {
|
||||||
if (isset($input['images']) && is_array($input['images'])) {
|
if (isset($input['images']) && is_array($input['images'])) {
|
||||||
$images = array_values(array_filter(
|
return clean_public_urls($input['images']);
|
||||||
array_map('trim', $input['images']),
|
|
||||||
fn($v) => $v !== ''
|
|
||||||
));
|
|
||||||
return $images;
|
|
||||||
}
|
}
|
||||||
if (isset($input['image']) && trim($input['image']) !== '') {
|
if (isset($input['image']) && trim($input['image']) !== '') {
|
||||||
return [trim($input['image'])];
|
$image = clean_public_url($input['image']);
|
||||||
|
return $image === '' ? [] : [$image];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -80,6 +77,7 @@ if ($method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// POST: 새 프로젝트 추가
|
// POST: 새 프로젝트 추가
|
||||||
@@ -113,11 +111,11 @@ if ($method === 'POST') {
|
|||||||
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
|
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
'image' => $images[0] ?? '',
|
'image' => $images[0] ?? '',
|
||||||
'link' => trim($input['link'] ?? ''),
|
'link' => clean_public_url($input['link'] ?? ''),
|
||||||
'stack' => $stack,
|
'stack' => $stack,
|
||||||
'period_start' => trim($input['period_start'] ?? ''),
|
'period_start' => trim($input['period_start'] ?? ''),
|
||||||
'period_end' => trim($input['period_end'] ?? ''),
|
'period_end' => trim($input['period_end'] ?? ''),
|
||||||
'video_url' => trim($input['video_url'] ?? ''),
|
'video_url' => clean_public_url($input['video_url'] ?? ''),
|
||||||
'created_at' => date('Y-m-d')
|
'created_at' => date('Y-m-d')
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -150,7 +148,7 @@ if ($method === 'PUT') {
|
|||||||
$projects[$key]['label'] = trim($input['label'] ?? $project['label']);
|
$projects[$key]['label'] = trim($input['label'] ?? $project['label']);
|
||||||
$projects[$key]['description'] = trim($input['description'] ?? $project['description']);
|
$projects[$key]['description'] = trim($input['description'] ?? $project['description']);
|
||||||
$projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']);
|
$projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']);
|
||||||
$projects[$key]['link'] = trim($input['link'] ?? $project['link']);
|
$projects[$key]['link'] = clean_public_url($input['link'] ?? $project['link']);
|
||||||
|
|
||||||
if (isset($input['images']) || isset($input['image'])) {
|
if (isset($input['images']) || isset($input['image'])) {
|
||||||
$images = normalize_images($input);
|
$images = normalize_images($input);
|
||||||
@@ -169,7 +167,7 @@ if ($method === 'PUT') {
|
|||||||
$projects[$key]['period_end'] = trim($input['period_end']);
|
$projects[$key]['period_end'] = trim($input['period_end']);
|
||||||
}
|
}
|
||||||
if (isset($input['video_url'])) {
|
if (isset($input['video_url'])) {
|
||||||
$projects[$key]['video_url'] = trim($input['video_url']);
|
$projects[$key]['video_url'] = clean_public_url($input['video_url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 demo_url 필드 제거
|
// 기존 demo_url 필드 제거
|
||||||
|
|||||||
+8
-2
@@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
require_once __DIR__ . '/error_config.php';
|
require_once __DIR__ . '/error_config.php';
|
||||||
session_start();
|
start_secure_session();
|
||||||
set_json_headers();
|
set_json_headers();
|
||||||
require_auth();
|
require_auth();
|
||||||
|
require_csrf();
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
json_response(['error' => 'POST만 허용됩니다'], 405);
|
json_response(['error' => 'POST만 허용됩니다'], 405);
|
||||||
@@ -225,5 +226,10 @@ if ($saved) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$err = error_get_last();
|
$err = error_get_last();
|
||||||
json_response(['error' => '파일 저장 실패', 'detail' => $err, 'target' => $target_path, 'tmp' => $file['tmp_name'], 'tmp_exists' => file_exists($file['tmp_name'])], 500);
|
error_log('Upload save failed: ' . json_encode([
|
||||||
|
'detail' => $err,
|
||||||
|
'target' => $target_path,
|
||||||
|
'tmp_exists' => file_exists($file['tmp_name'])
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
json_response(['error' => '파일 저장 실패'], 500);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-14
@@ -883,6 +883,7 @@ main { padding: 80px 8%; }
|
|||||||
<script>
|
<script>
|
||||||
// ===== 상태 =====
|
// ===== 상태 =====
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
let csrfToken = '';
|
||||||
|
|
||||||
// ===== 테마 =====
|
// ===== 테마 =====
|
||||||
function applyTheme(theme) {
|
function applyTheme(theme) {
|
||||||
@@ -977,10 +978,19 @@ async function checkAuth() {
|
|||||||
const res = await fetch('api/auth.php?action=check');
|
const res = await fetch('api/auth.php?action=check');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
isAdmin = data.authenticated === true;
|
isAdmin = data.authenticated === true;
|
||||||
|
csrfToken = data.csrf_token || '';
|
||||||
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
|
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
|
||||||
} catch (e) { isAdmin = false; }
|
} catch (e) { isAdmin = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csrfHeaders(headers = {}) {
|
||||||
|
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfFetch(url, options = {}) {
|
||||||
|
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
|
||||||
|
}
|
||||||
|
|
||||||
function openLoginModal() {
|
function openLoginModal() {
|
||||||
if (isAdmin) { logout(); return; }
|
if (isAdmin) { logout(); return; }
|
||||||
document.getElementById('loginModal').classList.add('active');
|
document.getElementById('loginModal').classList.add('active');
|
||||||
@@ -1004,6 +1014,7 @@ async function doLogin() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
csrfToken = data.csrf_token || csrfToken;
|
||||||
isAdmin = true;
|
isAdmin = true;
|
||||||
document.getElementById('adminControls').classList.remove('hidden');
|
document.getElementById('adminControls').classList.remove('hidden');
|
||||||
closeLoginModal();
|
closeLoginModal();
|
||||||
@@ -1017,7 +1028,8 @@ async function doLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('api/auth.php?action=logout', { method: 'POST' });
|
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
|
||||||
|
csrfToken = '';
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
document.getElementById('adminControls').classList.add('hidden');
|
document.getElementById('adminControls').classList.add('hidden');
|
||||||
await loadProjects();
|
await loadProjects();
|
||||||
@@ -1069,6 +1081,8 @@ function renderProjects() {
|
|||||||
grid.innerHTML = pageProjects.map(p => {
|
grid.innerHTML = pageProjects.map(p => {
|
||||||
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
|
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
|
||||||
const hasMultiple = images.length > 1;
|
const hasMultiple = images.length > 1;
|
||||||
|
const safeLink = sanitizeUrl(p.link);
|
||||||
|
const safeVideoUrl = sanitizeUrl(p.video_url);
|
||||||
const cardId = `card-${p.id}`;
|
const cardId = `card-${p.id}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -1087,7 +1101,7 @@ function renderProjects() {
|
|||||||
onmouseleave="resumeSlideshow('${cardId}')">
|
onmouseleave="resumeSlideshow('${cardId}')">
|
||||||
${images.map((img, i) => `
|
${images.map((img, i) => `
|
||||||
<div class="slideshow-slide ${i === 0 ? 'active' : ''}"
|
<div class="slideshow-slide ${i === 0 ? 'active' : ''}"
|
||||||
style="background-image:url('${escapeHtml(img)}')"></div>
|
style="background-image:url('${escapeHtml(sanitizeUrl(img))}')"></div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${hasMultiple ? `
|
${hasMultiple ? `
|
||||||
<button class="slideshow-arrow prev" onclick="slideshowPrev('${cardId}')" aria-label="이전">
|
<button class="slideshow-arrow prev" onclick="slideshowPrev('${cardId}')" aria-label="이전">
|
||||||
@@ -1125,10 +1139,10 @@ function renderProjects() {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-link git" target="_blank">
|
${safeLink ? `<a href="${escapeHtml(safeLink)}" class="btn-link git" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="fa-brands fa-git-alt"></i><span class="btn-text"> 소스 코드</span>
|
<i class="fa-brands fa-git-alt"></i><span class="btn-text"> 소스 코드</span>
|
||||||
</a>` : ''}
|
</a>` : ''}
|
||||||
${p.video_url ? `<a href="${escapeHtml(p.video_url)}" class="btn-link video" target="_blank">
|
${safeVideoUrl ? `<a href="${escapeHtml(safeVideoUrl)}" class="btn-link video" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="fa-solid fa-circle-play"></i><span class="btn-text"> 영상 보기</span>
|
<i class="fa-solid fa-circle-play"></i><span class="btn-text"> 영상 보기</span>
|
||||||
</a>` : ''}
|
</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -1734,7 +1748,7 @@ async function saveProject() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', item.file);
|
formData.append('file', item.file);
|
||||||
formData.append('project_title', title);
|
formData.append('project_title', title);
|
||||||
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
|
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!result.success) throw new Error(result.error || '이미지 업로드 실패');
|
if (!result.success) throw new Error(result.error || '이미지 업로드 실패');
|
||||||
URL.revokeObjectURL(item.url); // 메모리 해제
|
URL.revokeObjectURL(item.url); // 메모리 해제
|
||||||
@@ -1753,7 +1767,7 @@ async function saveProject() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', pendingVideoFile);
|
formData.append('file', pendingVideoFile);
|
||||||
formData.append('project_title', title);
|
formData.append('project_title', title);
|
||||||
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
|
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!result.success) throw new Error(result.error || '영상 업로드 실패');
|
if (!result.success) throw new Error(result.error || '영상 업로드 실패');
|
||||||
videoUrl = result.url;
|
videoUrl = result.url;
|
||||||
@@ -1784,13 +1798,13 @@ async function saveProject() {
|
|||||||
let res;
|
let res;
|
||||||
if (id) {
|
if (id) {
|
||||||
data.id = parseInt(id);
|
data.id = parseInt(id);
|
||||||
res = await fetch('api/projects.php', {
|
res = await csrfFetch('api/projects.php', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await fetch('api/projects.php', {
|
res = await csrfFetch('api/projects.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
@@ -1849,7 +1863,7 @@ async function deleteProject(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
|
const res = await csrfFetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
alert(result.error || '삭제 실패');
|
alert(result.error || '삭제 실패');
|
||||||
@@ -1858,16 +1872,14 @@ async function deleteProject(id) {
|
|||||||
|
|
||||||
if (deleteFolder && folderName) {
|
if (deleteFolder && folderName) {
|
||||||
try {
|
try {
|
||||||
const fileRes = await fetch('api/delete_files.php', {
|
const fileRes = await csrfFetch('api/delete_files.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ folder: folderName })
|
body: JSON.stringify({ folder: folderName })
|
||||||
});
|
});
|
||||||
const fileResult = await fileRes.json();
|
const fileResult = await fileRes.json();
|
||||||
if (fileResult.success) {
|
if (!fileResult.success || fileResult.failed_count > 0) {
|
||||||
if (fileResult.deleted_count > 0) {
|
alert('프로젝트는 삭제되었지만, 일부 파일 삭제에 실패했습니다.');
|
||||||
console.log(`이미지 폴더 정리: ${fileResult.deleted_count}개 파일 삭제됨`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('프로젝트는 삭제되었지만, 폴더 삭제 중 오류: ' + e.message);
|
alert('프로젝트는 삭제되었지만, 폴더 삭제 중 오류: ' + e.message);
|
||||||
@@ -1896,6 +1908,18 @@ function escapeHtml(str) {
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const trimmed = String(url).trim();
|
||||||
|
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
|
||||||
|
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
|
||||||
|
} catch (e) {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// ESC 키로 모달 닫기 (바깥 클릭은 무시 - 실수 방지)
|
// ESC 키로 모달 닫기 (바깥 클릭은 무시 - 실수 방지)
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|||||||
+110
-41
@@ -1585,7 +1585,7 @@ footer {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>색상</label>
|
<label>색상</label>
|
||||||
<input type="color" id="categoryColor" value="var(--primary)" style="height: 44px;">
|
<input type="color" id="categoryColor" value="#00f2ff" style="height: 44px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" onclick="saveCategory()">
|
<button class="btn btn-primary" onclick="saveCategory()">
|
||||||
@@ -1981,6 +1981,7 @@ function toggleTheme() {
|
|||||||
applyTheme(saved || prefer);
|
applyTheme(saved || prefer);
|
||||||
})();
|
})();
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
let csrfToken = '';
|
||||||
let categories = []; // 모든 카테고리 (parent + child)
|
let categories = []; // 모든 카테고리 (parent + child)
|
||||||
let posts = [];
|
let posts = [];
|
||||||
let currentFilter = { type: 'all', value: null };
|
let currentFilter = { type: 'all', value: null };
|
||||||
@@ -2013,10 +2014,7 @@ function renderMarkdown(src) {
|
|||||||
parts.forEach(p => {
|
parts.forEach(p => {
|
||||||
const m = p.match(/^w(?:idth)?\s*=\s*(.+)$/i);
|
const m = p.match(/^w(?:idth)?\s*=\s*(.+)$/i);
|
||||||
if (m) {
|
if (m) {
|
||||||
let w = m[1].trim();
|
result.width = sanitizeMediaWidth(m[1].trim());
|
||||||
// 숫자만 있으면 px 추가
|
|
||||||
if (/^\d+$/.test(w)) w = w + 'px';
|
|
||||||
result.width = w;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/^(left|center|right)$/i.test(p)) {
|
if (/^(left|center|right)$/i.test(p)) {
|
||||||
@@ -2040,6 +2038,8 @@ function renderMarkdown(src) {
|
|||||||
src = src.replace(
|
src = src.replace(
|
||||||
/@video(?:\[([^\]]*)\])?\(([^)]+)\)/g,
|
/@video(?:\[([^\]]*)\])?\(([^)]+)\)/g,
|
||||||
function(match, attrStr, url) {
|
function(match, attrStr, url) {
|
||||||
|
const safeUrl = sanitizeUrl(url);
|
||||||
|
if (!safeUrl) return '';
|
||||||
const attrs = parseAttrs(attrStr || '');
|
const attrs = parseAttrs(attrStr || '');
|
||||||
// width와 align은 wrap에 적용 (video 자체가 아님)
|
// width와 align은 wrap에 적용 (video 자체가 아님)
|
||||||
const styles = [];
|
const styles = [];
|
||||||
@@ -2048,7 +2048,7 @@ function renderMarkdown(src) {
|
|||||||
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
||||||
styles.push('max-width:100%');
|
styles.push('max-width:100%');
|
||||||
const wrapStyle = `style="${styles.join(';')}"`;
|
const wrapStyle = `style="${styles.join(';')}"`;
|
||||||
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${url.trim()}"></video></div>`;
|
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${escapeHtml(safeUrl)}"></video></div>`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2057,13 +2057,15 @@ function renderMarkdown(src) {
|
|||||||
src = src.replace(
|
src = src.replace(
|
||||||
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g,
|
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g,
|
||||||
function(match, alt, attrStr, url) {
|
function(match, alt, attrStr, url) {
|
||||||
|
const safeUrl = sanitizeUrl(url);
|
||||||
|
if (!safeUrl) return '';
|
||||||
const attrs = parseAttrs(attrStr);
|
const attrs = parseAttrs(attrStr);
|
||||||
const styles = [];
|
const styles = [];
|
||||||
if (attrs.width) styles.push(`width:${attrs.width}`);
|
if (attrs.width) styles.push(`width:${attrs.width}`);
|
||||||
styles.push('max-width:100%');
|
styles.push('max-width:100%');
|
||||||
const alignS = alignStyle(attrs.align);
|
const alignS = alignStyle(attrs.align);
|
||||||
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
||||||
return `<img src="${url.trim()}" alt="${alt}" style="${styles.join(';')};">`;
|
return `<img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(alt)}" style="${styles.join(';')};">`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2091,10 +2093,12 @@ function renderMarkdown(src) {
|
|||||||
src = src.replace(/==([^=\n\[]+)==/g, '<mark>$1</mark>');
|
src = src.replace(/==([^=\n\[]+)==/g, '<mark>$1</mark>');
|
||||||
|
|
||||||
// ===== 5) 색상: {color:#ff0000}text{/color} =====
|
// ===== 5) 색상: {color:#ff0000}text{/color} =====
|
||||||
src = src.replace(/\{color:(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\}([\s\S]+?)\{\/color\}/g,
|
src = src.replace(/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/g, function(match, color, text) {
|
||||||
'<span class="md-color" style="color:$1">$2</span>');
|
const safeColor = sanitizeCssColor(color);
|
||||||
|
return safeColor ? `<span class="md-color" style="color:${safeColor}">${text}</span>` : text;
|
||||||
|
});
|
||||||
|
|
||||||
return DOMPurify.sanitize(marked.parse(src));
|
return sanitizeAllowedStyles(DOMPurify.sanitize(marked.parse(src)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 렌더 후 비디오에 커스텀 컨트롤 부착
|
// 렌더 후 비디오에 커스텀 컨트롤 부착
|
||||||
@@ -2406,8 +2410,7 @@ function addMediaToolbar(wrap, kind, curWidth, curAlign) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyWidth(wrap, widthVal, kind) {
|
function applyWidth(wrap, widthVal, kind) {
|
||||||
// 숫자만 입력되면 px 추가
|
widthVal = sanitizeMediaWidth(widthVal);
|
||||||
if (/^\d+$/.test(widthVal)) widthVal = widthVal + 'px';
|
|
||||||
wrap.style.width = widthVal || '';
|
wrap.style.width = widthVal || '';
|
||||||
// 툴바 input 동기화
|
// 툴바 input 동기화
|
||||||
const input = wrap.querySelector('.md-toolbar-size-input');
|
const input = wrap.querySelector('.md-toolbar-size-input');
|
||||||
@@ -2486,10 +2489,19 @@ async function checkAuth() {
|
|||||||
const res = await fetch('api/auth.php?action=check');
|
const res = await fetch('api/auth.php?action=check');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
isAdmin = data.authenticated === true;
|
isAdmin = data.authenticated === true;
|
||||||
|
csrfToken = data.csrf_token || '';
|
||||||
document.body.classList.toggle('admin-on', isAdmin);
|
document.body.classList.toggle('admin-on', isAdmin);
|
||||||
} catch (e) { isAdmin = false; }
|
} catch (e) { isAdmin = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csrfHeaders(headers = {}) {
|
||||||
|
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfFetch(url, options = {}) {
|
||||||
|
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
|
||||||
|
}
|
||||||
|
|
||||||
function openLoginModal() {
|
function openLoginModal() {
|
||||||
if (isAdmin) { logout(); return; }
|
if (isAdmin) { logout(); return; }
|
||||||
document.getElementById('loginModal').classList.add('active');
|
document.getElementById('loginModal').classList.add('active');
|
||||||
@@ -2511,6 +2523,7 @@ async function doLogin() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
csrfToken = data.csrf_token || csrfToken;
|
||||||
isAdmin = true;
|
isAdmin = true;
|
||||||
document.body.classList.add('admin-on');
|
document.body.classList.add('admin-on');
|
||||||
closeLoginModal();
|
closeLoginModal();
|
||||||
@@ -2522,7 +2535,8 @@ async function doLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('api/auth.php?action=logout', { method: 'POST' });
|
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
|
||||||
|
csrfToken = '';
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
document.body.classList.remove('admin-on');
|
document.body.classList.remove('admin-on');
|
||||||
}
|
}
|
||||||
@@ -2746,7 +2760,7 @@ function renderCategoryList() {
|
|||||||
onclick="onParentClick(${parent.id})">
|
onclick="onParentClick(${parent.id})">
|
||||||
<div class="cat-name">
|
<div class="cat-name">
|
||||||
<i class="fa-solid fa-chevron-right cat-toggle"></i>
|
<i class="fa-solid fa-chevron-right cat-toggle"></i>
|
||||||
<span class="cat-dot" style="background: ${escapeHtml(parent.color)};"></span>
|
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(parent.color, '#00f2ff'))};"></span>
|
||||||
<span class="cat-label">${escapeHtml(parent.name)}</span>
|
<span class="cat-label">${escapeHtml(parent.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 6px;">
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
@@ -2781,7 +2795,7 @@ function renderCategoryList() {
|
|||||||
<div class="cat-item ${isActive ? 'active' : ''}"
|
<div class="cat-item ${isActive ? 'active' : ''}"
|
||||||
onclick="setFilter('category', ${child.id})">
|
onclick="setFilter('category', ${child.id})">
|
||||||
<div class="cat-name">
|
<div class="cat-name">
|
||||||
<span class="cat-dot" style="background: ${escapeHtml(child.color)};"></span>
|
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(child.color, '#00f2ff'))};"></span>
|
||||||
<span class="cat-label">${escapeHtml(child.name)}</span>
|
<span class="cat-label">${escapeHtml(child.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 6px;">
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
@@ -2909,10 +2923,6 @@ function renderPosts() {
|
|||||||
const postDate = (p.created_at || '').trim();
|
const postDate = (p.created_at || '').trim();
|
||||||
return postDate === dateFilter;
|
return postDate === dateFilter;
|
||||||
});
|
});
|
||||||
// 디버그: 일치하는 글 없으면 콘솔에 출력
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
console.log('[날짜 필터]', dateFilter, '- 전체 글의 날짜:', posts.map(p => p.created_at));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 (scope에 따라 분기)
|
// 검색 (scope에 따라 분기)
|
||||||
@@ -2959,7 +2969,7 @@ function renderPosts() {
|
|||||||
list.innerHTML = filtered.map(p => {
|
list.innerHTML = filtered.map(p => {
|
||||||
const cat = getCategoryById(p.category_id);
|
const cat = getCategoryById(p.category_id);
|
||||||
const parent = cat ? getCategoryById(cat.parent_id) : null;
|
const parent = cat ? getCategoryById(cat.parent_id) : null;
|
||||||
const catColor = cat ? cat.color : 'var(--primary)';
|
const catColor = cat ? sanitizeCssColor(cat.color, '#00f2ff') : '#00f2ff';
|
||||||
const preview = stripMarkdown(p.content || '').slice(0, 200);
|
const preview = stripMarkdown(p.content || '').slice(0, 200);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -2972,7 +2982,7 @@ function renderPosts() {
|
|||||||
</div>
|
</div>
|
||||||
${cat ? `
|
${cat ? `
|
||||||
<div class="post-cat-pill">
|
<div class="post-cat-pill">
|
||||||
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
|
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
|
||||||
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
||||||
${escapeHtml(cat.name)}
|
${escapeHtml(cat.name)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3012,7 +3022,7 @@ function openPostDetail(id) {
|
|||||||
if (cat) {
|
if (cat) {
|
||||||
metaParts.push(`
|
metaParts.push(`
|
||||||
<div class="post-cat-pill">
|
<div class="post-cat-pill">
|
||||||
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
|
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
|
||||||
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
||||||
${escapeHtml(cat.name)}
|
${escapeHtml(cat.name)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3109,7 +3119,7 @@ async function deleteCurrentPost() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 글 삭제
|
// 글 삭제
|
||||||
const res = await fetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
|
const res = await csrfFetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
alert(result.error || '글 삭제 실패');
|
alert(result.error || '글 삭제 실패');
|
||||||
@@ -3119,16 +3129,13 @@ async function deleteCurrentPost() {
|
|||||||
// 첨부 파일 삭제 (선택했을 때)
|
// 첨부 파일 삭제 (선택했을 때)
|
||||||
if (deleteFiles && attachedUrls.length > 0) {
|
if (deleteFiles && attachedUrls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const fileRes = await fetch('api/delete_files.php', {
|
const fileRes = await csrfFetch('api/delete_files.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ urls: attachedUrls })
|
body: JSON.stringify({ urls: attachedUrls })
|
||||||
});
|
});
|
||||||
const fileResult = await fileRes.json();
|
const fileResult = await fileRes.json();
|
||||||
if (fileResult.success) {
|
if (fileResult.success) {
|
||||||
if (fileResult.deleted_count > 0) {
|
|
||||||
console.log(`첨부 파일 ${fileResult.deleted_count}개 삭제됨`);
|
|
||||||
}
|
|
||||||
if (fileResult.failed_count > 0) {
|
if (fileResult.failed_count > 0) {
|
||||||
alert(`글은 삭제되었지만, ${fileResult.failed_count}개 파일 삭제에 실패했습니다.`);
|
alert(`글은 삭제되었지만, ${fileResult.failed_count}개 파일 삭제에 실패했습니다.`);
|
||||||
}
|
}
|
||||||
@@ -3523,9 +3530,9 @@ function buildMediaAttrs() {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
let width = '';
|
let width = '';
|
||||||
if (widthSel === 'custom') {
|
if (widthSel === 'custom') {
|
||||||
width = widthCust;
|
width = sanitizeMediaWidth(widthCust);
|
||||||
} else if (widthSel) {
|
} else if (widthSel) {
|
||||||
width = widthSel;
|
width = sanitizeMediaWidth(widthSel);
|
||||||
}
|
}
|
||||||
if (width) parts.push(`w=${width}`);
|
if (width) parts.push(`w=${width}`);
|
||||||
if (align) parts.push(align);
|
if (align) parts.push(align);
|
||||||
@@ -3568,11 +3575,11 @@ function insertAttachFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertMediaFromUrl() {
|
function insertMediaFromUrl() {
|
||||||
const url = document.getElementById('imageInsertUrl').value.trim();
|
const url = sanitizeUrl(document.getElementById('imageInsertUrl').value.trim());
|
||||||
const alt = document.getElementById('imageInsertAlt').value.trim();
|
const alt = document.getElementById('imageInsertAlt').value.trim();
|
||||||
const kind = document.getElementById('imageInsertKind').value;
|
const kind = document.getElementById('imageInsertKind').value;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showAlert('imageInsertAlert', 'URL을 입력해주세요', 'error');
|
showAlert('imageInsertAlert', 'http, https, uploads/ 경로만 사용할 수 있습니다', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertAtCursor(buildMediaMarkdown(kind, url, alt));
|
insertAtCursor(buildMediaMarkdown(kind, url, alt));
|
||||||
@@ -3667,7 +3674,7 @@ async function uploadPendingMedia(content) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('project_title', 'learning');
|
formData.append('project_title', 'learning');
|
||||||
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
|
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
|
||||||
if (result.success && result.url) {
|
if (result.success && result.url) {
|
||||||
@@ -3699,7 +3706,7 @@ async function uploadPendingMedia(content) {
|
|||||||
// ===== 색상 선택 =====
|
// ===== 색상 선택 =====
|
||||||
const COLOR_PALETTE = [
|
const COLOR_PALETTE = [
|
||||||
// 세이지/민트 계열
|
// 세이지/민트 계열
|
||||||
'var(--primary)', '#3d6b50', '#8ab89a', '#B2E2D2',
|
'#00f2ff', '#3d6b50', '#8ab89a', '#B2E2D2',
|
||||||
// 웜 뉴트럴
|
// 웜 뉴트럴
|
||||||
'#b07a20', '#c9a050', '#c8856a', '#9e6b52',
|
'#b07a20', '#c9a050', '#c8856a', '#9e6b52',
|
||||||
// 레드/핑크 (핀포인트)
|
// 레드/핑크 (핀포인트)
|
||||||
@@ -3751,7 +3758,8 @@ function closeColorPicker() {
|
|||||||
document.getElementById('colorPickerModal').classList.remove('active');
|
document.getElementById('colorPickerModal').classList.remove('active');
|
||||||
}
|
}
|
||||||
function applyColor(color) {
|
function applyColor(color) {
|
||||||
wrapSelection(`{color:${color}}`, '{/color}', '');
|
const safeColor = sanitizeCssColor(color);
|
||||||
|
if (safeColor) wrapSelection(`{color:${safeColor}}`, '{/color}', '');
|
||||||
closeColorPicker();
|
closeColorPicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3847,13 +3855,13 @@ async function savePost() {
|
|||||||
let res;
|
let res;
|
||||||
if (id) {
|
if (id) {
|
||||||
data.id = parseInt(id);
|
data.id = parseInt(id);
|
||||||
res = await fetch('api/learning.php', {
|
res = await csrfFetch('api/learning.php', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await fetch('api/learning.php', {
|
res = await csrfFetch('api/learning.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
@@ -3900,7 +3908,7 @@ function openCategoryModal(cat = null) {
|
|||||||
document.getElementById('categoryModalTitle').textContent = '카테고리 수정';
|
document.getElementById('categoryModalTitle').textContent = '카테고리 수정';
|
||||||
document.getElementById('categoryId').value = cat.id;
|
document.getElementById('categoryId').value = cat.id;
|
||||||
document.getElementById('categoryName').value = cat.name;
|
document.getElementById('categoryName').value = cat.name;
|
||||||
document.getElementById('categoryColor').value = cat.color || 'var(--primary)';
|
document.getElementById('categoryColor').value = sanitizeCssColor(cat.color, '#00f2ff');
|
||||||
parentSel.value = cat.parent_id || '';
|
parentSel.value = cat.parent_id || '';
|
||||||
// 자식이 있으면 부모 변경 비활성화
|
// 자식이 있으면 부모 변경 비활성화
|
||||||
const hasChildren = categories.some(c => c.parent_id === cat.id);
|
const hasChildren = categories.some(c => c.parent_id === cat.id);
|
||||||
@@ -3912,7 +3920,7 @@ function openCategoryModal(cat = null) {
|
|||||||
document.getElementById('categoryModalTitle').textContent = '카테고리 추가';
|
document.getElementById('categoryModalTitle').textContent = '카테고리 추가';
|
||||||
document.getElementById('categoryId').value = '';
|
document.getElementById('categoryId').value = '';
|
||||||
document.getElementById('categoryName').value = '';
|
document.getElementById('categoryName').value = '';
|
||||||
document.getElementById('categoryColor').value = 'var(--primary)';
|
document.getElementById('categoryColor').value = '#00f2ff';
|
||||||
parentSel.value = '';
|
parentSel.value = '';
|
||||||
parentSel.disabled = false;
|
parentSel.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -3966,7 +3974,7 @@ async function deleteCategory(id) {
|
|||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
|
const res = await csrfFetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 현재 필터가 영향받으면 전체로
|
// 현재 필터가 영향받으면 전체로
|
||||||
@@ -4002,13 +4010,13 @@ async function saveCategory() {
|
|||||||
let res;
|
let res;
|
||||||
if (id) {
|
if (id) {
|
||||||
payload.id = parseInt(id);
|
payload.id = parseInt(id);
|
||||||
res = await fetch('api/categories.php', {
|
res = await csrfFetch('api/categories.php', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await fetch('api/categories.php', {
|
res = await csrfFetch('api/categories.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -4049,6 +4057,67 @@ function escapeHtml(str) {
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const trimmed = String(url).trim();
|
||||||
|
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
|
||||||
|
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
|
||||||
|
} catch (e) {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCssColor(color, fallback = '') {
|
||||||
|
const trimmed = String(color || '').trim();
|
||||||
|
return /^#[0-9a-fA-F]{3}$/.test(trimmed) || /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMediaWidth(width) {
|
||||||
|
let value = String(width || '').trim();
|
||||||
|
if (/^\d+$/.test(value)) value += 'px';
|
||||||
|
const px = value.match(/^(\d{1,4})px$/);
|
||||||
|
if (px) return Math.min(Math.max(parseInt(px[1], 10), 40), 1200) + 'px';
|
||||||
|
const pct = value.match(/^(\d{1,3})%$/);
|
||||||
|
if (pct) return Math.min(Math.max(parseInt(pct[1], 10), 1), 100) + '%';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAllowedStyles(html) {
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html;
|
||||||
|
template.content.querySelectorAll('[style]').forEach(el => {
|
||||||
|
const allowed = [];
|
||||||
|
const declarations = String(el.getAttribute('style') || '').split(';');
|
||||||
|
declarations.forEach(decl => {
|
||||||
|
const [rawProp, ...rawValueParts] = decl.split(':');
|
||||||
|
if (!rawProp || rawValueParts.length === 0) return;
|
||||||
|
const prop = rawProp.trim().toLowerCase();
|
||||||
|
const value = rawValueParts.join(':').trim();
|
||||||
|
if (prop === 'width') {
|
||||||
|
const width = sanitizeMediaWidth(value);
|
||||||
|
if (width) allowed.push(`width:${width}`);
|
||||||
|
} else if (prop === 'max-width' && value === '100%') {
|
||||||
|
allowed.push('max-width:100%');
|
||||||
|
} else if (prop === 'display' && value === 'block') {
|
||||||
|
allowed.push('display:block');
|
||||||
|
} else if ((prop === 'margin-left' || prop === 'margin-right') && /^(auto|0)$/.test(value)) {
|
||||||
|
allowed.push(`${prop}:${value}`);
|
||||||
|
} else if (prop === 'color') {
|
||||||
|
const color = sanitizeCssColor(value);
|
||||||
|
if (color) allowed.push(`color:${color}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (allowed.length > 0) {
|
||||||
|
el.setAttribute('style', allowed.join(';'));
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return template.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// ESC / 백스페이스로 모달 닫기
|
// ESC / 백스페이스로 모달 닫기
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|||||||
+27
-3
@@ -695,6 +695,7 @@ function toggleTheme() {
|
|||||||
applyTheme(saved || prefer);
|
applyTheme(saved || prefer);
|
||||||
})();
|
})();
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
let csrfToken = '';
|
||||||
let profile = null;
|
let profile = null;
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -707,12 +708,21 @@ async function checkAuth() {
|
|||||||
const res = await fetch('api/auth.php?action=check');
|
const res = await fetch('api/auth.php?action=check');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
isAdmin = data.authenticated === true;
|
isAdmin = data.authenticated === true;
|
||||||
|
csrfToken = data.csrf_token || '';
|
||||||
document.querySelectorAll('.admin-controls').forEach(el => {
|
document.querySelectorAll('.admin-controls').forEach(el => {
|
||||||
el.classList.toggle('hidden', !isAdmin);
|
el.classList.toggle('hidden', !isAdmin);
|
||||||
});
|
});
|
||||||
} catch (e) { isAdmin = false; }
|
} catch (e) { isAdmin = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csrfHeaders(headers = {}) {
|
||||||
|
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfFetch(url, options = {}) {
|
||||||
|
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/profile.php');
|
const res = await fetch('api/profile.php');
|
||||||
@@ -734,7 +744,7 @@ function renderProfile() {
|
|||||||
|
|
||||||
const avatarEl = document.getElementById('avatarEl');
|
const avatarEl = document.getElementById('avatarEl');
|
||||||
if (profile.avatar) {
|
if (profile.avatar) {
|
||||||
avatarEl.style.backgroundImage = `url('${escapeAttr(profile.avatar)}')`;
|
avatarEl.style.backgroundImage = `url('${escapeAttr(sanitizeUrl(profile.avatar))}')`;
|
||||||
avatarEl.style.backgroundSize = 'cover';
|
avatarEl.style.backgroundSize = 'cover';
|
||||||
avatarEl.style.backgroundPosition = 'center';
|
avatarEl.style.backgroundPosition = 'center';
|
||||||
avatarEl.innerHTML = '';
|
avatarEl.innerHTML = '';
|
||||||
@@ -818,8 +828,10 @@ function renderProfile() {
|
|||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
const safeHref = sanitizeUrl(c.href);
|
||||||
|
if (!safeHref) return '';
|
||||||
return `
|
return `
|
||||||
<a href="${escapeAttr(c.href)}" class="contact-card" target="_blank">
|
<a href="${escapeAttr(safeHref)}" class="contact-card" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="${c.icon}"></i>
|
<i class="${c.icon}"></i>
|
||||||
<span class="label">${c.label}</span>
|
<span class="label">${c.label}</span>
|
||||||
<span class="value">${escapeHtml(c.value)}</span>
|
<span class="value">${escapeHtml(c.value)}</span>
|
||||||
@@ -1040,7 +1052,7 @@ async function saveProfile() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/profile.php', {
|
const res = await csrfFetch('api/profile.php', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
@@ -1071,6 +1083,18 @@ function escapeHtml(str) {
|
|||||||
}
|
}
|
||||||
function escapeAttr(str) { return escapeHtml(str); }
|
function escapeAttr(str) { return escapeHtml(str); }
|
||||||
|
|
||||||
|
function sanitizeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const trimmed = String(url).trim();
|
||||||
|
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
|
||||||
|
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
|
||||||
|
} catch (e) {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// ESC 키로 모달 닫기 (바깥 클릭 닫기 제거)
|
// ESC 키로 모달 닫기 (바깥 클릭 닫기 제거)
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|||||||
Reference in New Issue
Block a user