Compare commits
4 Commits
db81f0e4a4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ae02cdccf2 | |||
| ae72b4c739 | |||
| b27968e5a7 | |||
| afb752da00 |
@@ -0,0 +1,36 @@
|
|||||||
|
# Local agent/task workspace
|
||||||
|
.strideterm/
|
||||||
|
|
||||||
|
# Local secrets and environment overrides
|
||||||
|
api/config.local.php
|
||||||
|
api/config.local.php*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
*.log
|
||||||
|
api/logs/*
|
||||||
|
!api/logs/.htaccess
|
||||||
|
|
||||||
|
# User-uploaded/generated media
|
||||||
|
uploads/*
|
||||||
|
!uploads/.htaccess
|
||||||
|
!uploads/**/.htaccess
|
||||||
|
|
||||||
|
# Local backups and temporary files
|
||||||
|
backup_*/
|
||||||
|
*_backup.*
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
|
||||||
|
# OS/editor noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -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` 설정 필요.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -1,32 +1,50 @@
|
|||||||
<?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;
|
||||||
@@ -44,32 +62,25 @@ 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)) {
|
|
||||||
ftruncate($fp, 0);
|
|
||||||
rewind($fp);
|
|
||||||
fwrite($fp, $json);
|
|
||||||
fflush($fp);
|
|
||||||
flock($fp, LOCK_UN);
|
|
||||||
fclose($fp);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
fclose($fp);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!rename($tmp, $filepath)) {
|
||||||
// 인증 체크
|
@unlink($tmp);
|
||||||
function require_auth() {
|
return false;
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
session_start();
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_auth() {
|
||||||
|
start_secure_session();
|
||||||
|
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
@@ -77,7 +88,6 @@ function require_auth() {
|
|||||||
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();
|
||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 로그 디렉토리 웹 직접 접근 차단
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 필드 제거
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -1,639 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>이종재 | Game & XR Developer</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #00f2ff;
|
|
||||||
--secondary: #7000ff;
|
|
||||||
--bg-dark: #0a0a0f;
|
|
||||||
--card-bg: #161625;
|
|
||||||
--text-white: #e2e8f0;
|
|
||||||
--text-dim: #94a3b8;
|
|
||||||
--danger: #ff4757;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Noto Sans KR', sans-serif;
|
|
||||||
background-color: var(--bg-dark);
|
|
||||||
color: var(--text-white);
|
|
||||||
line-height: 1.7;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, .logo, .card-label { font-family: 'Orbitron', sans-serif; }
|
|
||||||
|
|
||||||
nav {
|
|
||||||
background: rgba(10, 10, 15, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
padding: 1.2rem 8%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
border-bottom: 1px solid rgba(0, 242, 255, 0.1);
|
|
||||||
}
|
|
||||||
nav .logo { font-weight: 700; font-size: 1.4rem; color: var(--primary); letter-spacing: 2px; }
|
|
||||||
nav .links { display: flex; align-items: center; gap: 2rem; }
|
|
||||||
nav .links a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text-white);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: 0.3s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
nav .links a:hover { color: var(--primary); }
|
|
||||||
nav .links a.profile-link {
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
nav .links a.profile-link:hover {
|
|
||||||
background: var(--primary);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
min-height: 60vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 80px 20px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 30% 50%, rgba(112, 0, 255, 0.15) 0%, transparent 50%),
|
|
||||||
radial-gradient(circle at 70% 50%, rgba(0, 242, 255, 0.1) 0%, transparent 50%);
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
header h1 {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-shadow: 0 0 30px rgba(0, 242, 255, 0.5);
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
header p { font-size: 1.2rem; color: var(--text-dim); max-width: 800px; padding: 0 20px; }
|
|
||||||
.highlight { color: var(--primary); }
|
|
||||||
|
|
||||||
main { padding: 80px 8%; }
|
|
||||||
.section-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4rem; }
|
|
||||||
.section-header h2 { font-size: 2rem; letter-spacing: 1px; }
|
|
||||||
.line { flex-grow: 1; height: 1px; background: rgba(0, 242, 255, 0.2); }
|
|
||||||
|
|
||||||
.admin-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.admin-controls.hidden { display: none; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: 'Orbitron', sans-serif;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: 0.3s;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.btn-primary { background: var(--primary); color: #000; font-weight: 700; }
|
|
||||||
.btn-primary:hover { background: #fff; transform: translateY(-2px); }
|
|
||||||
.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); }
|
|
||||||
.btn-outline:hover { background: var(--primary); color: #000; }
|
|
||||||
.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
|
|
||||||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
||||||
gap: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: 1.2rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255,255,255,0.05);
|
|
||||||
transition: 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.card:hover { transform: translateY(-10px); border-color: var(--primary); box-shadow: 0 10px 40px rgba(0, 242, 255, 0.1); }
|
|
||||||
|
|
||||||
.card-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 200px;
|
|
||||||
background: linear-gradient(135deg, #1e1e30 0%, #2a1a3e 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 3.5rem;
|
|
||||||
color: rgba(255,255,255,0.15);
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content { padding: 1.8rem; }
|
|
||||||
.card-label {
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.card h3 { font-size: 1.4rem; margin-bottom: 0.8rem; }
|
|
||||||
.card p { color: var(--text-dim); margin-bottom: 1.5rem; font-size: 0.95rem; }
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.card-actions.admin-mode {
|
|
||||||
border-top: 1px dashed rgba(255,255,255,0.1);
|
|
||||||
padding-top: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-git {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #000;
|
|
||||||
background: var(--primary);
|
|
||||||
padding: 0.7rem 1.2rem;
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
.btn-git:hover { background: white; }
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; }
|
|
||||||
|
|
||||||
/* 모달 */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
z-index: 2000;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.modal-overlay.active { display: flex; }
|
|
||||||
.modal {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid rgba(0, 242, 255, 0.3);
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 550px;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.modal h2 {
|
|
||||||
color: var(--primary);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
.form-group { margin-bottom: 1.2rem; }
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: 'Orbitron', sans-serif;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
.form-group input, .form-group textarea, .form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: #0a0a0f;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-white);
|
|
||||||
font-family: 'Noto Sans KR', sans-serif;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 242, 255, 0.1);
|
|
||||||
}
|
|
||||||
.form-group textarea { min-height: 100px; resize: vertical; }
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
.alert {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.alert-error { background: rgba(255, 71, 87, 0.1); border: 1px solid var(--danger); color: var(--danger); }
|
|
||||||
.alert-success { background: rgba(0, 242, 255, 0.1); border: 1px solid var(--primary); color: var(--primary); }
|
|
||||||
.icon-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.icon-hint a { color: var(--primary); }
|
|
||||||
|
|
||||||
footer {
|
|
||||||
padding: 60px 8% 30px;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
footer .admin-toggle {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 10px;
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
footer .admin-toggle:hover { opacity: 1; color: var(--primary); }
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
header h1 { font-size: 2.2rem; }
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
nav { padding: 1rem 5%; }
|
|
||||||
nav .links { gap: 1rem; }
|
|
||||||
main { padding: 60px 5%; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<div class="logo">JONGJAE.XR</div>
|
|
||||||
<div class="links">
|
|
||||||
<a href="#work">PROJECTS</a>
|
|
||||||
<a href="profile.html" class="profile-link">
|
|
||||||
<i class="fa-solid fa-user"></i> PROFILE
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>이종재 <span class="highlight">Portfolio</span></h1>
|
|
||||||
<p>Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.<br>Gitea 서버를 통해 실제 <span class="highlight">소스 코드</span>를 확인하실 수 있습니다.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="work">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>DEVELOPMENT LOG</h2>
|
|
||||||
<div class="line"></div>
|
|
||||||
<div class="admin-controls hidden" id="adminControls">
|
|
||||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
|
||||||
<i class="fa-solid fa-plus"></i> 새 프로젝트
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline" onclick="logout()">
|
|
||||||
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid" id="projectsGrid">
|
|
||||||
<!-- 프로젝트가 동적으로 로드됩니다 -->
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>© 2026 Lee Jong-jae. Hosted on Private Synology NAS.</p>
|
|
||||||
<span class="admin-toggle" onclick="openLoginModal()" title="Admin">
|
|
||||||
<i class="fa-solid fa-shield-halved"></i>
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- 로그인 모달 -->
|
|
||||||
<div class="modal-overlay" id="loginModal">
|
|
||||||
<div class="modal">
|
|
||||||
<h2><i class="fa-solid fa-lock"></i> ADMIN LOGIN</h2>
|
|
||||||
<div id="loginAlert"></div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>비밀번호</label>
|
|
||||||
<input type="password" id="loginPassword" placeholder="비밀번호를 입력하세요"
|
|
||||||
onkeypress="if(event.key==='Enter') doLogin()">
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn btn-outline" onclick="closeLoginModal()">취소</button>
|
|
||||||
<button class="btn btn-primary" onclick="doLogin()">로그인</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 프로젝트 등록/수정 모달 -->
|
|
||||||
<div class="modal-overlay" id="projectModal">
|
|
||||||
<div class="modal">
|
|
||||||
<h2 id="projectModalTitle">새 프로젝트</h2>
|
|
||||||
<div id="projectAlert"></div>
|
|
||||||
<input type="hidden" id="projectId">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>제목 *</label>
|
|
||||||
<input type="text" id="projectTitle" placeholder="예: XR 인터랙티브 시뮬레이션">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>라벨 *</label>
|
|
||||||
<input type="text" id="projectLabel" placeholder="예: UNITY / XR">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>설명 *</label>
|
|
||||||
<textarea id="projectDescription" placeholder="프로젝트 소개"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>아이콘 (Font Awesome 클래스)</label>
|
|
||||||
<input type="text" id="projectIcon" placeholder="예: fa-solid fa-cube">
|
|
||||||
<p class="icon-hint">예시: <code>fa-solid fa-cube</code> · <code>fa-solid fa-gamepad</code> · <code>fa-solid fa-microchip</code> · <a href="https://fontawesome.com/search?o=r&m=free" target="_blank">아이콘 검색</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>썸네일 이미지 URL (선택)</label>
|
|
||||||
<input type="text" id="projectImage" placeholder="비우면 아이콘 표시">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>링크 URL (Gitea 저장소 등)</label>
|
|
||||||
<input type="text" id="projectLink" placeholder="https://...">
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn btn-outline" onclick="closeProjectModal()">취소</button>
|
|
||||||
<button class="btn btn-primary" onclick="saveProject()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> 저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ===== 상태 =====
|
|
||||||
let isAdmin = false;
|
|
||||||
let projects = [];
|
|
||||||
|
|
||||||
// ===== 초기화 =====
|
|
||||||
async function init() {
|
|
||||||
await checkAuth();
|
|
||||||
await loadProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 인증 =====
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/auth.php?action=check');
|
|
||||||
const data = await res.json();
|
|
||||||
isAdmin = data.authenticated === true;
|
|
||||||
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
|
|
||||||
} catch (e) {
|
|
||||||
isAdmin = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLoginModal() {
|
|
||||||
if (isAdmin) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('loginModal').classList.add('active');
|
|
||||||
document.getElementById('loginPassword').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLoginModal() {
|
|
||||||
document.getElementById('loginModal').classList.remove('active');
|
|
||||||
document.getElementById('loginPassword').value = '';
|
|
||||||
document.getElementById('loginAlert').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doLogin() {
|
|
||||||
const password = document.getElementById('loginPassword').value;
|
|
||||||
if (!password) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/auth.php?action=login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
isAdmin = true;
|
|
||||||
document.getElementById('adminControls').classList.remove('hidden');
|
|
||||||
closeLoginModal();
|
|
||||||
await loadProjects();
|
|
||||||
} else {
|
|
||||||
showAlert('loginAlert', data.error || '로그인 실패', 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('loginAlert', '서버 오류', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch('api/auth.php?action=logout', { method: 'POST' });
|
|
||||||
isAdmin = false;
|
|
||||||
document.getElementById('adminControls').classList.add('hidden');
|
|
||||||
await loadProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 프로젝트 로드 =====
|
|
||||||
async function loadProjects() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/projects.php');
|
|
||||||
projects = await res.json();
|
|
||||||
renderProjects();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load projects', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProjects() {
|
|
||||||
const grid = document.getElementById('projectsGrid');
|
|
||||||
|
|
||||||
if (!projects || projects.length === 0) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="empty-state" style="grid-column: 1/-1;">
|
|
||||||
<i class="fa-solid fa-folder-open"></i>
|
|
||||||
<p>아직 등록된 프로젝트가 없습니다.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.innerHTML = projects.map(p => `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-img" ${p.image ? `style="background-image:url('${escapeHtml(p.image)}'); font-size:0;"` : ''}>
|
|
||||||
${!p.image ? `<i class="${escapeHtml(p.icon || 'fa-solid fa-code')}"></i>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<span class="card-label">${escapeHtml(p.label)}</span>
|
|
||||||
<h3>${escapeHtml(p.title)}</h3>
|
|
||||||
<p>${escapeHtml(p.description)}</p>
|
|
||||||
<div class="card-actions">
|
|
||||||
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-git" target="_blank">
|
|
||||||
<i class="fa-brands fa-git-alt"></i> 소스 코드 보기
|
|
||||||
</a>` : ''}
|
|
||||||
</div>
|
|
||||||
${isAdmin ? `
|
|
||||||
<div class="card-actions admin-mode">
|
|
||||||
<button class="btn btn-outline" onclick="editProject(${p.id})">
|
|
||||||
<i class="fa-solid fa-pen"></i> 수정
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger" onclick="deleteProject(${p.id})">
|
|
||||||
<i class="fa-solid fa-trash"></i> 삭제
|
|
||||||
</button>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 프로젝트 모달 =====
|
|
||||||
function openProjectModal(project = null) {
|
|
||||||
document.getElementById('projectAlert').innerHTML = '';
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
document.getElementById('projectModalTitle').textContent = '프로젝트 수정';
|
|
||||||
document.getElementById('projectId').value = project.id;
|
|
||||||
document.getElementById('projectTitle').value = project.title;
|
|
||||||
document.getElementById('projectLabel').value = project.label;
|
|
||||||
document.getElementById('projectDescription').value = project.description;
|
|
||||||
document.getElementById('projectIcon').value = project.icon || '';
|
|
||||||
document.getElementById('projectImage').value = project.image || '';
|
|
||||||
document.getElementById('projectLink').value = project.link || '';
|
|
||||||
} else {
|
|
||||||
document.getElementById('projectModalTitle').textContent = '새 프로젝트';
|
|
||||||
document.getElementById('projectId').value = '';
|
|
||||||
['projectTitle', 'projectLabel', 'projectDescription',
|
|
||||||
'projectIcon', 'projectImage', 'projectLink'].forEach(id => {
|
|
||||||
document.getElementById(id).value = '';
|
|
||||||
});
|
|
||||||
document.getElementById('projectIcon').value = 'fa-solid fa-code';
|
|
||||||
}
|
|
||||||
document.getElementById('projectModal').classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeProjectModal() {
|
|
||||||
document.getElementById('projectModal').classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editProject(id) {
|
|
||||||
const project = projects.find(p => p.id === id);
|
|
||||||
if (project) openProjectModal(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveProject() {
|
|
||||||
const id = document.getElementById('projectId').value;
|
|
||||||
const data = {
|
|
||||||
title: document.getElementById('projectTitle').value.trim(),
|
|
||||||
label: document.getElementById('projectLabel').value.trim(),
|
|
||||||
description: document.getElementById('projectDescription').value.trim(),
|
|
||||||
icon: document.getElementById('projectIcon').value.trim(),
|
|
||||||
image: document.getElementById('projectImage').value.trim(),
|
|
||||||
link: document.getElementById('projectLink').value.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.title || !data.label || !data.description) {
|
|
||||||
showAlert('projectAlert', '제목, 라벨, 설명은 필수입니다', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let res;
|
|
||||||
if (id) {
|
|
||||||
data.id = parseInt(id);
|
|
||||||
res = await fetch('api/projects.php', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res = await fetch('api/projects.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await res.json();
|
|
||||||
if (result.success) {
|
|
||||||
closeProjectModal();
|
|
||||||
await loadProjects();
|
|
||||||
} else {
|
|
||||||
showAlert('projectAlert', result.error || '저장 실패', 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('projectAlert', '서버 오류', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteProject(id) {
|
|
||||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
|
|
||||||
const result = await res.json();
|
|
||||||
if (result.success) {
|
|
||||||
await loadProjects();
|
|
||||||
} else {
|
|
||||||
alert(result.error || '삭제 실패');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('서버 오류');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 유틸 =====
|
|
||||||
function showAlert(elemId, message, type) {
|
|
||||||
const elem = document.getElementById(elemId);
|
|
||||||
elem.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
|
|
||||||
if (type === 'success') {
|
|
||||||
setTimeout(() => elem.innerHTML = '', 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (str === null || str === undefined) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모달 외부 클릭 시 닫기
|
|
||||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) overlay.classList.remove('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 927 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1019 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,536 +0,0 @@
|
|||||||
# 단타 자동매매 시스템 종합 기획서
|
|
||||||
|
|
||||||
> 버전: v1.0
|
|
||||||
> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker
|
|
||||||
> 원칙: 규칙 기반 완전 자동화, 인간 판단 개입 0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 설계 원칙 (절대 불변)
|
|
||||||
|
|
||||||
1. **감정 0** — 모든 진입/청산은 코드가 결정. 예외 없음
|
|
||||||
2. **손절 우선** — 수익 극대화보다 손실 제한이 먼저
|
|
||||||
3. **단순함 우선** — 복잡한 전략보다 단순하고 견고한 전략
|
|
||||||
4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래 순서 필수
|
|
||||||
5. **14:50 전량 청산** — 오버나이트는 절대 없음. 하드코딩
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 시스템 전체 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Synology NAS (Docker) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ stockbot │ │ kill-switch │ ← 별도 컨테이너 │
|
|
||||||
│ │ (메인) │ │ (긴급중단) │ │
|
|
||||||
│ └──────┬───────┘ └──────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────▼───────────────────────────────────┐ │
|
|
||||||
│ │ asyncio Event Loop │ │
|
|
||||||
│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │
|
|
||||||
│ │ │Universe │ │Strategy │ │ Risk │ │ │
|
|
||||||
│ │ │Scanner │ │Engine │ │ Manager │ │ │
|
|
||||||
│ │ └─────────┘ └─────────┘ └───────────┘ │ │
|
|
||||||
│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │
|
|
||||||
│ │ │ Data │ │ Order │ │ Notifier │ │ │
|
|
||||||
│ │ │Collector│ │Executor │ │(Telegram) │ │ │
|
|
||||||
│ │ └─────────┘ └─────────┘ └───────────┘ │ │
|
|
||||||
│ └───────────────────────┬──────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────────────────▼──────────────────┐ │
|
|
||||||
│ │ SQLite (체결/포지션/로그) │ │
|
|
||||||
│ │ Redis (실시간 시세 캐시) │ │
|
|
||||||
│ └──────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────┐ │
|
|
||||||
│ │ Streamlit Dashboard (포트 8501) │ │
|
|
||||||
│ └──────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
KIS WebSocket KIS REST API
|
|
||||||
(실시간 시세) (주문/잔고)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 기술 스택 (확정)
|
|
||||||
|
|
||||||
| 항목 | 선택 | 이유 |
|
|
||||||
|------|------|------|
|
|
||||||
| 언어 | Python 3.11 | 생태계, KIS 예제 코드 모두 Python |
|
|
||||||
| 비동기 | asyncio + aiohttp | 초당 20건 rate limit 정밀 제어 |
|
|
||||||
| DB | SQLite | NAS 메모리 절약, 단일 파일 백업 용이 |
|
|
||||||
| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시, TTL 관리 |
|
|
||||||
| 스케줄러 | APScheduler 3.x (AsyncIOScheduler) | 장 시작/마감 이벤트 트리거 |
|
|
||||||
| 알림 | python-telegram-bot | 단일 채널, 신뢰성 높음 |
|
|
||||||
| 대시보드 | Streamlit | 설치 단순, NAS 내부망 접근 |
|
|
||||||
| 컨테이너 | Docker Compose | Synology Container Manager 호환 |
|
|
||||||
| 백테스트 | vectorbt | pandas 기반, 분봉 데이터 처리 빠름 |
|
|
||||||
| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 디렉토리 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
/volume1/docker/stockbot/
|
|
||||||
│
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env ← API 키 전용 (Git 제외)
|
|
||||||
│
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py ← 진입점, asyncio 루프
|
|
||||||
│ ├── config.py ← 전략 파라미터 전용
|
|
||||||
│ │
|
|
||||||
│ ├── data/
|
|
||||||
│ │ ├── collector.py ← KIS WebSocket 시세 수신
|
|
||||||
│ │ ├── universe.py ← 종목 풀 갱신 (08:30)
|
|
||||||
│ │ └── backfill.py ← 과거 분봉 백필 스크립트
|
|
||||||
│ │
|
|
||||||
│ ├── strategy/
|
|
||||||
│ │ ├── base.py ← 전략 추상 클래스
|
|
||||||
│ │ └── volatility_breakout.py ← 전략 A (유일한 실전 전략)
|
|
||||||
│ │
|
|
||||||
│ ├── risk/
|
|
||||||
│ │ └── manager.py ← 손절/일일한도/강제청산
|
|
||||||
│ │
|
|
||||||
│ ├── execution/
|
|
||||||
│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼
|
|
||||||
│ │ └── order_executor.py ← 주문 전송, 재시도, 슬리피지
|
|
||||||
│ │
|
|
||||||
│ ├── monitor/
|
|
||||||
│ │ ├── notifier.py ← 텔레그램 알림
|
|
||||||
│ │ └── dashboard.py ← Streamlit 대시보드
|
|
||||||
│ │
|
|
||||||
│ └── db/
|
|
||||||
│ ├── models.py ← SQLite 스키마
|
|
||||||
│ └── repository.py ← DB 접근 레이어
|
|
||||||
│
|
|
||||||
├── kill_switch/
|
|
||||||
│ └── kill.py ← 긴급 전량 청산 단독 실행
|
|
||||||
│
|
|
||||||
├── backtest/
|
|
||||||
│ ├── run_backtest.py ← vectorbt 백테스트 실행
|
|
||||||
│ └── results/ ← 백테스트 결과 저장
|
|
||||||
│
|
|
||||||
├── data/
|
|
||||||
│ ├── stockbot.db ← SQLite DB
|
|
||||||
│ └── universe_cache.json ← 당일 종목 풀 캐시
|
|
||||||
│
|
|
||||||
└── logs/
|
|
||||||
└── trades.log ← 영구 보관 (세금 신고용)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 전략 (확정: 전략 A 단독)
|
|
||||||
|
|
||||||
### 변동성 돌파 전략 (Volatility Breakout)
|
|
||||||
|
|
||||||
**선정 이유:**
|
|
||||||
- 래리 윌리엄스 실전 검증, 30년 이상 데이터 기반
|
|
||||||
- 룰이 단순 → 과적합 위험 최소
|
|
||||||
- 분봉 적용 시 한국 장 오전 변동성과 궁합 최적
|
|
||||||
- 진입 조건이 명확 → 자동화 구현 난이도 낮음
|
|
||||||
|
|
||||||
**핵심 파라미터 (config.py에서 관리)**
|
|
||||||
```python
|
|
||||||
STRATEGY_K = 0.5 # 변동성 계수 (0.4~0.6 안정 구간)
|
|
||||||
ENTRY_START = "09:00" # 진입 허용 시작
|
|
||||||
ENTRY_END = "14:30" # 진입 허용 마감
|
|
||||||
FORCE_EXIT = "14:50" # 강제 청산 시각 (하드코딩)
|
|
||||||
TP1_PCT = 0.02 # 1차 익절 +2% (50% 매도)
|
|
||||||
TP2_PCT = 0.03 # 2차 익절 +3% (전량)
|
|
||||||
SL_PCT = 0.015 # 손절 -1.5% (즉시 시장가)
|
|
||||||
MAX_HOLD_MIN = 120 # 최대 보유시간 120분 (무수익 시 청산)
|
|
||||||
```
|
|
||||||
|
|
||||||
**진입 로직**
|
|
||||||
```
|
|
||||||
목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K
|
|
||||||
|
|
||||||
진입 조건 (전부 충족 시):
|
|
||||||
1. 현재가 >= 목표가
|
|
||||||
2. 현재 시각 ENTRY_START ~ ENTRY_END
|
|
||||||
3. 당일 KOSPI 등락률 > -1.0% ← 시장 약세 차단
|
|
||||||
4. 전일 거래대금 >= 100억
|
|
||||||
5. 시가총액 1,000억 ~ 3조
|
|
||||||
6. VI 미발동 종목
|
|
||||||
7. 당일 보유 종목 수 < 2 ← 동시 최대 2종목
|
|
||||||
8. 일일 누적 손실 < -3% ← 손실 한도 미도달
|
|
||||||
```
|
|
||||||
|
|
||||||
**청산 로직 (우선순위 순)**
|
|
||||||
```
|
|
||||||
1순위: 강제 청산 → 14:50 시장가 전량
|
|
||||||
2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) → 시장가
|
|
||||||
3순위: 1차 익절 → 현재가 >= 매수가 × (1 + TP1_PCT) → 50% 지정가
|
|
||||||
4순위: 2차 익절 → 현재가 >= 매수가 × (1 + TP2_PCT) → 전량 지정가
|
|
||||||
5순위: 시간 청산 → 보유 후 MAX_HOLD_MIN 경과, 수익 없을 시 시장가
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 종목 유니버스 선정 (Universe)
|
|
||||||
|
|
||||||
매일 08:30 자동 갱신.
|
|
||||||
|
|
||||||
**1차 필터 (정적)**
|
|
||||||
| 조건 | 기준 |
|
|
||||||
|------|------|
|
|
||||||
| 시장 | 코스피 + 코스닥 |
|
|
||||||
| 시가총액 | 1,000억 ~ 3조 |
|
|
||||||
| 상장 기간 | 6개월 이상 |
|
|
||||||
| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN |
|
|
||||||
|
|
||||||
**2차 필터 (동적, 전일 기준)**
|
|
||||||
| 조건 | 기준 |
|
|
||||||
|------|------|
|
|
||||||
| 전일 거래대금 | 100억 이상 |
|
|
||||||
| 5일 평균 거래대금 | 50억 이상 |
|
|
||||||
| 전일 등락률 | -3% ~ +15% |
|
|
||||||
| 일봉 60일 이평선 | 현재가 위 |
|
|
||||||
|
|
||||||
**결과: 최대 30종목** (KIS WebSocket 안정성 기준으로 50 → 30 축소)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 리스크 관리
|
|
||||||
|
|
||||||
### 포지션 규칙
|
|
||||||
| 항목 | 값 |
|
|
||||||
|------|-----|
|
|
||||||
| 1종목 최대 비중 | 총자산 × 20% |
|
|
||||||
| 동시 보유 최대 | 2종목 |
|
|
||||||
| 분할매수 | 1차 10%, 신호 유지 시 2차 10% |
|
|
||||||
|
|
||||||
### 손실 한도 (계층별 자동 중단)
|
|
||||||
| 레벨 | 조건 | 동작 |
|
|
||||||
|------|------|------|
|
|
||||||
| L1 | 1회 매매 -1.5% | 즉시 손절 |
|
|
||||||
| L2 | 일일 누적 -3% | 당일 신규 진입 중단 |
|
|
||||||
| L3 | 3연속 손절 | 당일 매매 중단 |
|
|
||||||
| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 |
|
|
||||||
| L5 | 월간 누적 -15% | 자동 전략 폐기 + 백테스트 재요청 |
|
|
||||||
|
|
||||||
### 안전장치
|
|
||||||
- VI(변동성완화장치) 발동 → 즉시 해당 종목 청산
|
|
||||||
- 호가 스프레드 > 0.5% → 진입 금지
|
|
||||||
- 매수 후 5분 미체결 → 주문 취소
|
|
||||||
- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산
|
|
||||||
- API 오류 10건/분 → kill-switch 컨테이너 자동 실행
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. KIS API 운용
|
|
||||||
|
|
||||||
### 초당 20건 제한 대응 전략
|
|
||||||
```
|
|
||||||
WebSocket (실시간 시세) : 30종목 구독 → REST 호출 없음
|
|
||||||
REST (주문/잔고) : 주문 시에만 호출
|
|
||||||
REST (분봉 조회) : 신호 확인용, 1초 간격 제한
|
|
||||||
|
|
||||||
asyncio Semaphore(20) 로 초당 20건 보장
|
|
||||||
배치 처리: 30종목 ÷ 20건 = 1.5초/1회전
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebSocket 구독 항목
|
|
||||||
- 실시간 체결가 (H0STCNT0)
|
|
||||||
- 실시간 호가 (H0STASP0)
|
|
||||||
- VI 발동/해제 (H0STVI0)
|
|
||||||
|
|
||||||
### 주문 타입 결정
|
|
||||||
| 상황 | 주문 타입 |
|
|
||||||
|------|----------|
|
|
||||||
| 진입 | 시장가 (빠른 체결 우선) |
|
|
||||||
| 1차 익절 | 지정가 (목표가 미리 걸기) |
|
|
||||||
| 2차 익절 | 지정가 |
|
|
||||||
| 손절 | 시장가 (슬리피지 감수) |
|
|
||||||
| 강제청산 | 시장가 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 스케줄 타임라인
|
|
||||||
|
|
||||||
```
|
|
||||||
08:00 │ 시스템 기동, DB 연결 확인
|
|
||||||
08:30 │ Universe 갱신 (pykrx 전일 데이터)
|
|
||||||
08:50 │ WebSocket 연결, 30종목 구독 시작
|
|
||||||
│ 당일 시가 기반 목표가 계산
|
|
||||||
09:00 │ ─────────── 진입 허용 시작 ───────────
|
|
||||||
│ 1분봉 루프 시작 (asyncio, 1초 단위)
|
|
||||||
11:30 │ 신규 진입 일시 중단 (점심)
|
|
||||||
13:00 │ 신규 진입 재개
|
|
||||||
14:30 │ 신규 진입 마감
|
|
||||||
14:50 │ ─────────── 강제 청산 ───────────────
|
|
||||||
│ 보유 포지션 전량 시장가 매도
|
|
||||||
15:00 │ WebSocket 연결 종료
|
|
||||||
15:10 │ 당일 결산 로그 생성
|
|
||||||
15:20 │ 텔레그램 일일 결산 알림 발송
|
|
||||||
15:30 │ 시스템 대기 (다음 날 08:00까지)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. DB 스키마 (SQLite)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 체결 내역
|
|
||||||
CREATE TABLE trades (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
|
||||||
ticker TEXT NOT NULL,
|
|
||||||
entry_time TEXT NOT NULL, -- HH:MM:SS
|
|
||||||
exit_time TEXT,
|
|
||||||
entry_price REAL NOT NULL,
|
|
||||||
exit_price REAL,
|
|
||||||
quantity INTEGER NOT NULL,
|
|
||||||
side TEXT NOT NULL, -- BUY / SELL
|
|
||||||
exit_reason TEXT, -- TP1/TP2/SL/FORCE/TIME
|
|
||||||
pnl REAL,
|
|
||||||
fee REAL,
|
|
||||||
slippage REAL,
|
|
||||||
strategy TEXT DEFAULT 'VB'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 일일 요약
|
|
||||||
CREATE TABLE daily_summary (
|
|
||||||
date TEXT PRIMARY KEY,
|
|
||||||
total_trades INTEGER,
|
|
||||||
win_trades INTEGER,
|
|
||||||
lose_trades INTEGER,
|
|
||||||
gross_pnl REAL,
|
|
||||||
total_fee REAL,
|
|
||||||
net_pnl REAL,
|
|
||||||
max_drawdown REAL,
|
|
||||||
trading_stopped INTEGER DEFAULT 0 -- L2 발동 여부
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 포지션 (장중 현황)
|
|
||||||
CREATE TABLE positions (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
entry_time TEXT,
|
|
||||||
entry_price REAL,
|
|
||||||
quantity INTEGER,
|
|
||||||
tp1_done INTEGER DEFAULT 0,
|
|
||||||
target_price REAL,
|
|
||||||
stop_price REAL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 알림 설계 (텔레그램)
|
|
||||||
|
|
||||||
| 이벤트 | 메시지 형식 |
|
|
||||||
|--------|-----------|
|
|
||||||
| 진입 | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` |
|
|
||||||
| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` |
|
|
||||||
| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` |
|
|
||||||
| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` |
|
|
||||||
| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` |
|
|
||||||
| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2%` |
|
|
||||||
| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 백테스트 계획
|
|
||||||
|
|
||||||
### 데이터 준비
|
|
||||||
```
|
|
||||||
수집 대상: 코스피200 + 코스닥150 편입 종목
|
|
||||||
기간: 2021-01-01 ~ 2024-12-31 (4년치)
|
|
||||||
봉: 1분봉
|
|
||||||
도구: pykrx (무료, 과거 분봉 제공)
|
|
||||||
생존편향: 상장폐지 종목 별도 수집 포함
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수수료 시뮬레이션
|
|
||||||
```
|
|
||||||
매수 수수료: 0.015%
|
|
||||||
매도 수수료: 0.015%
|
|
||||||
거래세: 0.18% (매도 시)
|
|
||||||
슬리피지: 시장가 0.10%, 지정가 0.03%
|
|
||||||
총 1회전 비용 추정: 약 0.21%
|
|
||||||
```
|
|
||||||
|
|
||||||
### 합격 기준 (out-of-sample 1년 기준)
|
|
||||||
| 지표 | 통과 기준 | 목표 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 샤프지수 | > 1.0 | > 1.5 |
|
|
||||||
| MDD | < 15% | < 10% |
|
|
||||||
| 승률 | > 45% | > 55% |
|
|
||||||
| 손익비 | > 1.3 | > 1.8 |
|
|
||||||
| 일평균 매매 | 1~5회 | 2~3회 |
|
|
||||||
|
|
||||||
### K값 최적화 방법
|
|
||||||
```
|
|
||||||
1. K = 0.3 ~ 0.8 구간 0.05 단위 그리드 서치
|
|
||||||
2. 인샘플(2021~2023) 에서 상위 3개 K값 선별
|
|
||||||
3. 아웃샘플(2024) 에서 재검증
|
|
||||||
4. 피크 K값 배제 → 샤프지수 안정 구간 채택
|
|
||||||
5. 최종 채택: K = 0.5 (사전 기본값, 백테스트로 재확인)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 인프라 (Synology Docker)
|
|
||||||
|
|
||||||
### docker-compose.yml
|
|
||||||
```yaml
|
|
||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: stockbot-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./data/redis:/data
|
|
||||||
|
|
||||||
stockbot:
|
|
||||||
build: ./app
|
|
||||||
container_name: stockbot-main
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Seoul
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
|
|
||||||
kill-switch:
|
|
||||||
build: ./kill_switch
|
|
||||||
container_name: stockbot-killswitch
|
|
||||||
restart: "no" # 수동 또는 자동 트리거 시에만 실행
|
|
||||||
env_file: .env
|
|
||||||
profiles: ["emergency"] # docker compose --profile emergency up
|
|
||||||
|
|
||||||
dashboard:
|
|
||||||
build: ./monitor
|
|
||||||
container_name: stockbot-dashboard
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8501:8501"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Seoul
|
|
||||||
```
|
|
||||||
|
|
||||||
### .env 구조
|
|
||||||
```
|
|
||||||
# KIS API
|
|
||||||
KIS_APP_KEY=...
|
|
||||||
KIS_APP_SECRET=...
|
|
||||||
KIS_ACCOUNT_NO=...
|
|
||||||
KIS_MOCK=true # 모의투자: true / 실거래: false
|
|
||||||
|
|
||||||
# 텔레그램
|
|
||||||
TELEGRAM_TOKEN=...
|
|
||||||
TELEGRAM_CHAT_ID=...
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_HOST=stockbot-redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# 운영 모드
|
|
||||||
DRY_RUN=true # 주문 실제 전송 여부
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 개발 로드맵
|
|
||||||
|
|
||||||
### Phase 1 — 데이터 기반 구축 (2주)
|
|
||||||
- [ ] KIS Open API 신청 + 모의투자 계좌 개설
|
|
||||||
- [ ] pykrx로 2021~2024 분봉 데이터 백필
|
|
||||||
- [ ] SQLite 스키마 생성
|
|
||||||
- [ ] Universe 스캐너 구현 (08:30 갱신)
|
|
||||||
- [ ] Docker Compose 기동 확인 (NAS)
|
|
||||||
|
|
||||||
### Phase 2 — 백테스트 (3주)
|
|
||||||
- [ ] vectorbt 환경 구성
|
|
||||||
- [ ] 변동성 돌파 전략 백테스트 코드 작성
|
|
||||||
- [ ] 수수료/슬리피지 반영
|
|
||||||
- [ ] K값 그리드 서치
|
|
||||||
- [ ] out-of-sample 검증 → 합격 기준 통과 확인
|
|
||||||
|
|
||||||
### Phase 3 — KIS 연동 + dry-run (2주)
|
|
||||||
- [ ] KIS REST 클라이언트 구현 (인증/토큰 갱신)
|
|
||||||
- [ ] KIS WebSocket 클라이언트 구현 (시세 수신)
|
|
||||||
- [ ] OrderExecutor 구현 (시장가/지정가/IOC)
|
|
||||||
- [ ] DRY_RUN=true 상태에서 신호 발생 확인
|
|
||||||
|
|
||||||
### Phase 4 — 리스크 + 알림 + 대시보드 (2주)
|
|
||||||
- [ ] RiskManager 전 계층 구현 (L1~L5)
|
|
||||||
- [ ] kill-switch 컨테이너 구현
|
|
||||||
- [ ] 텔레그램 Notifier 구현
|
|
||||||
- [ ] Streamlit 대시보드 구현 (실시간 PnL)
|
|
||||||
|
|
||||||
### Phase 5 — 모의투자 실운영 (최소 3개월)
|
|
||||||
- [ ] KIS_MOCK=true, DRY_RUN=false
|
|
||||||
- [ ] 매일 결산 로그 검토
|
|
||||||
- [ ] 주간 샤프지수/MDD 추적
|
|
||||||
- [ ] 이상 거동 발생 시 전략 파라미터 재검토 (코드 수정, K값 조정)
|
|
||||||
- [ ] 3개월 연속 샤프 > 1.0, MDD < 15% 달성 시 Phase 6 진입
|
|
||||||
|
|
||||||
### Phase 6 — 소액 실거래 (무기한)
|
|
||||||
- [ ] KIS_MOCK=false
|
|
||||||
- [ ] 총자산의 5% 한도로 시작
|
|
||||||
- [ ] 1개월 단위 성과 검토, 한도 점진 확대
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 보안 체크리스트
|
|
||||||
|
|
||||||
- [ ] .env → .gitignore 등록 필수
|
|
||||||
- [ ] KIS API 키 → GitHub 절대 커밋 금지
|
|
||||||
- [ ] Synology 방화벽: 8501 포트 내부망만 허용
|
|
||||||
- [ ] 텔레그램 봇: 특정 chat_id만 허용 (화이트리스트)
|
|
||||||
- [ ] 모든 주문 로그 SQLite + logs/ 이중 보관
|
|
||||||
- [ ] 월 1회 .env 파일 암호화 백업
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. 절대 금지 항목 (하드코딩)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 이 리스트는 코드에서 상수로 관리, 절대 런타임 수정 불가
|
|
||||||
BLACKLIST_REASONS = [
|
|
||||||
"신규상장 6개월 미만",
|
|
||||||
"관리종목",
|
|
||||||
"투자경고",
|
|
||||||
"거래정지",
|
|
||||||
"우선주",
|
|
||||||
"스팩",
|
|
||||||
"ETF/ETN",
|
|
||||||
]
|
|
||||||
|
|
||||||
TRADING_BLACKOUT = [
|
|
||||||
("11:30", "13:00"), # 점심 휴식
|
|
||||||
("14:50", "15:30"), # 장 마감 전
|
|
||||||
("08:00", "09:00"), # 동시호가
|
|
||||||
]
|
|
||||||
|
|
||||||
HARD_EXIT_TIME = "14:50" # 절대 변경 불가
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. 면책 조항
|
|
||||||
|
|
||||||
> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다.
|
|
||||||
> 단타는 통계적으로 개인투자자의 90% 이상이 손실을 보는 영역이다.
|
|
||||||
> 백테스트 결과는 미래 수익을 보장하지 않으며, 반드시 모의투자 3개월 이상 검증 후 실거래로 전환할 것.
|
|
||||||
> 시스템 장애, API 오류, 네트워크 단절로 인한 손실에 대해 어떠한 책임도 지지 않는다.
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
# 단타 자동매매 시스템 종합 기획서 v2.0
|
|
||||||
|
|
||||||
> 버전: v2.0 (AI 판단 레이어 통합)
|
|
||||||
> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker
|
|
||||||
> 핵심 변경: 규칙 기반 실행 + Claude AI 일일 시장 판단 레이어 추가
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 설계 원칙 (절대 불변)
|
|
||||||
|
|
||||||
1. **감정 0** — 실행은 코드가 결정. 단, 판단은 AI가 보조
|
|
||||||
2. **속도/판단 역할 분리** — AI는 느리지만 깊게(매일 1회), 실행은 빠르게(수식)
|
|
||||||
3. **손절 우선** — AI가 긍정 판단해도 손절 룰은 무조건 우선
|
|
||||||
4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래
|
|
||||||
5. **14:50 전량 청산** — 하드코딩, 어떤 상황에서도 예외 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 시스템 전체 구조 (v2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ Synology NAS (Docker) │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ [레이어 1] AI 판단 엔진 (08:00) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 데이터 수집 │ │
|
|
||||||
│ │ ├─ 전일 뉴스 (네이버 금융 크롤링) │ │
|
|
||||||
│ │ ├─ KOSPI/KOSDAQ 지수 흐름 (KIS REST) │ │
|
|
||||||
│ │ ├─ 외국인/기관 순매수 상위 (KIS 순위분석 API) │ │
|
|
||||||
│ │ ├─ 거래량 급증 종목 (KIS 순위분석 API) │ │
|
|
||||||
│ │ └─ 섹터별 등락률 (KIS 업종/기타 API) │ │
|
|
||||||
│ │ ↓ │ │
|
|
||||||
│ │ Claude API 호출 (하루 1회, 약 3,000 토큰) │ │
|
|
||||||
│ │ ↓ │ │
|
|
||||||
│ │ daily_context.json 생성 │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ 읽기만 함 │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ [레이어 2] 규칙 기반 실행 엔진 (09:00~) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ asyncio Event Loop │ │
|
|
||||||
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
|
|
||||||
│ │ │Universe │ │Strategy │ │ Risk │ │ │
|
|
||||||
│ │ │Scanner │ │Engine │ │ Manager │ │ │
|
|
||||||
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
|
|
||||||
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
|
|
||||||
│ │ │ Data │ │ Order │ │ Notifier │ │ │
|
|
||||||
│ │ │Collector │ │Executor │ │(Telegram) │ │ │
|
|
||||||
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ SQLite (체결/포지션/로그) │ Redis (실시간 시세 캐시) │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Streamlit Dashboard (포트 8501) │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ kill-switch (별도 컨테이너 / 긴급 청산) │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
KIS WebSocket KIS REST API
|
|
||||||
(실시간 시세/VI) (주문/잔고/순위/수급)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 핵심 변경: AI 판단 레이어 상세
|
|
||||||
|
|
||||||
### 2-1. 수집 데이터 소스
|
|
||||||
|
|
||||||
| 데이터 | 소스 | 방법 | 수집 시각 |
|
|
||||||
|--------|------|------|---------|
|
|
||||||
| 전일 종목 뉴스 헤드라인 | 네이버 금융 | HTTP 크롤링 | 07:30 |
|
|
||||||
| KOSPI/KOSDAQ 전일 종가·등락률 | KIS REST | API | 07:40 |
|
|
||||||
| 외국인 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 |
|
|
||||||
| 기관 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 |
|
|
||||||
| 거래량 급증 상위 30종목 | KIS 순위분석 API | API | 07:40 |
|
|
||||||
| 업종별 등락률 | KIS 업종/기타 API | API | 07:40 |
|
|
||||||
| 당일 경제 일정 (FOMC 등) | 네이버 금융 크롤링 | HTTP 크롤링 | 07:30 |
|
|
||||||
|
|
||||||
> KIS API만으로 수급·수치 데이터 대부분 커버 가능. 뉴스는 네이버 금융 크롤링으로 보완.
|
|
||||||
|
|
||||||
### 2-2. Claude API 프롬프트 구조
|
|
||||||
|
|
||||||
```python
|
|
||||||
SYSTEM_PROMPT = """
|
|
||||||
당신은 한국 주식 단타 전문 AI 분석가입니다.
|
|
||||||
매일 장 시작 전, 제공된 데이터를 분석해 오늘 단타 매매 전략을 판단합니다.
|
|
||||||
반드시 JSON 형식으로만 응답하며, 다른 텍스트는 포함하지 않습니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
USER_PROMPT = f"""
|
|
||||||
[전일 시장 요약]
|
|
||||||
- KOSPI: {kospi_change}% ({kospi_close}pt)
|
|
||||||
- KOSDAQ: {kosdaq_change}% ({kosdaq_close}pt)
|
|
||||||
|
|
||||||
[외국인 순매수 상위]
|
|
||||||
{foreign_buy_top10}
|
|
||||||
|
|
||||||
[기관 순매수 상위]
|
|
||||||
{institution_buy_top10}
|
|
||||||
|
|
||||||
[거래량 급증 상위]
|
|
||||||
{volume_surge_top10}
|
|
||||||
|
|
||||||
[업종별 등락률]
|
|
||||||
{sector_changes}
|
|
||||||
|
|
||||||
[주요 뉴스 헤드라인 (상위 20건)]
|
|
||||||
{news_headlines}
|
|
||||||
|
|
||||||
[오늘 경제 일정]
|
|
||||||
{economic_calendar}
|
|
||||||
|
|
||||||
위 데이터를 분석해 다음 JSON을 반환하세요:
|
|
||||||
{{
|
|
||||||
"trade_allowed": true/false,
|
|
||||||
"market_sentiment": "강세/중립/약세",
|
|
||||||
"sentiment_score": 0~100,
|
|
||||||
"risk_level": "낮음/보통/높음",
|
|
||||||
"hot_sectors": ["섹터1", "섹터2"],
|
|
||||||
"avoid_sectors": ["섹터3"],
|
|
||||||
"boosted_tickers": ["005930", "000660"],
|
|
||||||
"blacklist_tickers": ["종목코드"],
|
|
||||||
"position_size_multiplier": 0.5~1.5,
|
|
||||||
"reason": "한 줄 판단 이유"
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2-3. daily_context.json 구조
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"date": "2026-05-13",
|
|
||||||
"generated_at": "08:05:22",
|
|
||||||
"trade_allowed": true,
|
|
||||||
"market_sentiment": "중립",
|
|
||||||
"sentiment_score": 62,
|
|
||||||
"risk_level": "보통",
|
|
||||||
"hot_sectors": ["반도체", "2차전지"],
|
|
||||||
"avoid_sectors": ["금융", "건설"],
|
|
||||||
"boosted_tickers": ["005930", "000660", "373220"],
|
|
||||||
"blacklist_tickers": [],
|
|
||||||
"position_size_multiplier": 1.0,
|
|
||||||
"reason": "외국인 반도체 순매수 지속, KOSPI 박스권 상단 접근 중립 판단"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2-4. 실행 엔진에서의 활용
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 진입 조건에 AI 판단 필터 추가 (기존 조건 1~8 + 신규 9~11)
|
|
||||||
진입 조건 (전부 충족 시):
|
|
||||||
1. 현재가 >= 목표가 (변동성 돌파)
|
|
||||||
2. 현재 시각 09:00 ~ 14:30
|
|
||||||
3. KOSPI 등락률 > -1.0%
|
|
||||||
4. 전일 거래대금 >= 100억
|
|
||||||
5. 시가총액 1,000억 ~ 3조
|
|
||||||
6. VI 미발동
|
|
||||||
7. 보유 종목 수 < 2
|
|
||||||
8. 일일 누적 손실 < -3%
|
|
||||||
── AI 판단 필터 (신규) ──
|
|
||||||
9. daily_context["trade_allowed"] == true
|
|
||||||
10. 해당 종목 섹터가 avoid_sectors에 없음
|
|
||||||
11. 해당 종목이 blacklist_tickers에 없음
|
|
||||||
|
|
||||||
# 포지션 사이즈 조정
|
|
||||||
실제_투자비중 = 기본비중(20%) × position_size_multiplier
|
|
||||||
# sentiment_score 높을수록 multiplier 증가 (0.5~1.5)
|
|
||||||
|
|
||||||
# boosted_tickers 우선 진입
|
|
||||||
boosted 종목은 동일 신호 시 다른 종목보다 먼저 진입 처리
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 기술 스택 (v2.0 확정)
|
|
||||||
|
|
||||||
| 항목 | 선택 | 이유 |
|
|
||||||
|------|------|------|
|
|
||||||
| 언어 | Python 3.11 | KIS 예제 코드 모두 Python |
|
|
||||||
| 비동기 | asyncio + aiohttp | rate limit 정밀 제어 |
|
|
||||||
| DB | SQLite | NAS 메모리 절약, 백업 단순 |
|
|
||||||
| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시 |
|
|
||||||
| 스케줄러 | APScheduler (AsyncIOScheduler) | 장 이벤트 트리거 |
|
|
||||||
| AI 판단 | Claude claude-sonnet-4-20250514 | 뉴스/수급 분석 |
|
|
||||||
| 뉴스 수집 | aiohttp + BeautifulSoup4 | 네이버 금융 크롤링 |
|
|
||||||
| 알림 | python-telegram-bot | 텔레그램 단일 채널 |
|
|
||||||
| 대시보드 | Streamlit | NAS 내부망 접근 |
|
|
||||||
| 컨테이너 | Docker Compose | Synology Container Manager |
|
|
||||||
| 백테스트 | vectorbt | 분봉 데이터 고속 처리 |
|
|
||||||
| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 디렉토리 구조 (v2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
/volume1/docker/stockbot/
|
|
||||||
│
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env
|
|
||||||
│
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py ← 진입점, asyncio 루프
|
|
||||||
│ ├── config.py ← 전략 파라미터
|
|
||||||
│ │
|
|
||||||
│ ├── ai/ ← [신규] AI 판단 레이어
|
|
||||||
│ │ ├── context_builder.py ← 데이터 수집 + Claude API 호출
|
|
||||||
│ │ ├── news_crawler.py ← 네이버 금융 뉴스 크롤링
|
|
||||||
│ │ └── prompts.py ← 프롬프트 템플릿 관리
|
|
||||||
│ │
|
|
||||||
│ ├── data/
|
|
||||||
│ │ ├── collector.py ← KIS WebSocket 시세 수신
|
|
||||||
│ │ ├── universe.py ← 종목 풀 갱신 (08:30)
|
|
||||||
│ │ └── backfill.py ← 과거 분봉 백필
|
|
||||||
│ │
|
|
||||||
│ ├── strategy/
|
|
||||||
│ │ ├── base.py ← 전략 추상 클래스
|
|
||||||
│ │ └── volatility_breakout.py ← 변동성 돌파 전략 (AI 필터 포함)
|
|
||||||
│ │
|
|
||||||
│ ├── risk/
|
|
||||||
│ │ └── manager.py ← 손절/일일한도/강제청산
|
|
||||||
│ │
|
|
||||||
│ ├── execution/
|
|
||||||
│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼
|
|
||||||
│ │ └── order_executor.py ← 주문 전송, 재시도
|
|
||||||
│ │
|
|
||||||
│ ├── monitor/
|
|
||||||
│ │ ├── notifier.py ← 텔레그램 알림
|
|
||||||
│ │ └── dashboard.py ← Streamlit 대시보드
|
|
||||||
│ │
|
|
||||||
│ └── db/
|
|
||||||
│ ├── models.py ← SQLite 스키마
|
|
||||||
│ └── repository.py ← DB 접근 레이어
|
|
||||||
│
|
|
||||||
├── kill_switch/
|
|
||||||
│ └── kill.py
|
|
||||||
│
|
|
||||||
├── backtest/
|
|
||||||
│ ├── run_backtest.py
|
|
||||||
│ └── results/
|
|
||||||
│
|
|
||||||
├── data/
|
|
||||||
│ ├── stockbot.db
|
|
||||||
│ ├── universe_cache.json
|
|
||||||
│ └── daily_context.json ← [신규] AI 판단 결과 파일
|
|
||||||
│
|
|
||||||
└── logs/
|
|
||||||
├── trades.log
|
|
||||||
└── ai_context.log ← [신규] AI 판단 이력 보관
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 전략 (확정: 변동성 돌파 + AI 필터)
|
|
||||||
|
|
||||||
### 핵심 파라미터 (config.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 변동성 돌파 파라미터
|
|
||||||
STRATEGY_K = 0.5
|
|
||||||
ENTRY_START = "09:00"
|
|
||||||
ENTRY_END = "14:30"
|
|
||||||
FORCE_EXIT = "14:50"
|
|
||||||
TP1_PCT = 0.02
|
|
||||||
TP2_PCT = 0.03
|
|
||||||
SL_PCT = 0.015
|
|
||||||
MAX_HOLD_MIN = 120
|
|
||||||
|
|
||||||
# AI 판단 파라미터 (신규)
|
|
||||||
AI_CONTEXT_PATH = "/app/data/daily_context.json"
|
|
||||||
AI_MIN_SCORE = 40 # sentiment_score 40 미만 시 trade_allowed=false 강제
|
|
||||||
AI_BOOST_MULTI = 1.5 # boosted_tickers 진입 비중 배율
|
|
||||||
AI_RISK_SL_MAP = { # risk_level별 손절 강화
|
|
||||||
"낮음": 0.015,
|
|
||||||
"보통": 0.015,
|
|
||||||
"높음": 0.010 # 고위험 장세엔 손절 타이트하게
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 진입 로직 (AI 필터 통합)
|
|
||||||
|
|
||||||
```
|
|
||||||
목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K
|
|
||||||
|
|
||||||
진입 조건 (전부 충족):
|
|
||||||
[기술적 조건]
|
|
||||||
1. 현재가 >= 목표가
|
|
||||||
2. 09:00 ~ 14:30
|
|
||||||
3. KOSPI 등락률 > -1.0%
|
|
||||||
4. 전일 거래대금 >= 100억
|
|
||||||
5. 시가총액 1,000억 ~ 3조
|
|
||||||
6. VI 미발동
|
|
||||||
7. 보유 종목 수 < 2
|
|
||||||
8. 일일 누적 손실 < -3%
|
|
||||||
|
|
||||||
[AI 판단 조건]
|
|
||||||
9. trade_allowed == true
|
|
||||||
10. 종목 섹터 not in avoid_sectors
|
|
||||||
11. 종목 not in blacklist_tickers
|
|
||||||
```
|
|
||||||
|
|
||||||
### 청산 로직 (우선순위)
|
|
||||||
|
|
||||||
```
|
|
||||||
1순위: 14:50 강제 청산 (시장가)
|
|
||||||
2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) [risk_level에 따라 조정]
|
|
||||||
3순위: 1차 익절 → +TP1_PCT 도달 → 50% 지정가
|
|
||||||
4순위: 2차 익절 → +TP2_PCT 도달 → 전량 지정가
|
|
||||||
5순위: 시간 청산 → MAX_HOLD_MIN 경과 + 무수익
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 종목 유니버스 (Universe)
|
|
||||||
|
|
||||||
매일 08:30 자동 갱신.
|
|
||||||
|
|
||||||
**1차 필터 (정적)**
|
|
||||||
| 조건 | 기준 |
|
|
||||||
|------|------|
|
|
||||||
| 시장 | 코스피 + 코스닥 |
|
|
||||||
| 시가총액 | 1,000억 ~ 3조 |
|
|
||||||
| 상장 기간 | 6개월 이상 |
|
|
||||||
| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN |
|
|
||||||
|
|
||||||
**2차 필터 (동적)**
|
|
||||||
| 조건 | 기준 |
|
|
||||||
|------|------|
|
|
||||||
| 전일 거래대금 | 100억 이상 |
|
|
||||||
| 5일 평균 거래대금 | 50억 이상 |
|
|
||||||
| 전일 등락률 | -3% ~ +15% |
|
|
||||||
| 60일 이평선 | 현재가 위 |
|
|
||||||
|
|
||||||
**AI 보정**
|
|
||||||
- boosted_tickers → 우선 감시 목록 상단 배치
|
|
||||||
- blacklist_tickers → 당일 유니버스에서 즉시 제거
|
|
||||||
- avoid_sectors → 해당 섹터 전체 진입 금지
|
|
||||||
|
|
||||||
**최대 감시 종목: 30개** (WebSocket 안정성 기준)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 리스크 관리
|
|
||||||
|
|
||||||
### 포지션 규칙
|
|
||||||
| 항목 | 기본값 | AI 조정 |
|
|
||||||
|------|--------|--------|
|
|
||||||
| 1종목 최대 비중 | 총자산 × 20% | × position_size_multiplier |
|
|
||||||
| 동시 보유 최대 | 2종목 | risk_level=높음 시 1종목 |
|
|
||||||
| 손절 기준 | -1.5% | risk_level=높음 시 -1.0% |
|
|
||||||
|
|
||||||
### 손실 한도 (계층별)
|
|
||||||
| 레벨 | 조건 | 동작 |
|
|
||||||
|------|------|------|
|
|
||||||
| L1 | 1회 매매 -1.5% | 즉시 손절 |
|
|
||||||
| L2 | 일일 누적 -3% | 당일 신규 진입 중단 |
|
|
||||||
| L3 | 3연속 손절 | 당일 매매 중단 |
|
|
||||||
| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 |
|
|
||||||
| L5 | 월간 누적 -15% | 전략 폐기 + 백테스트 재실행 |
|
|
||||||
|
|
||||||
### 안전장치
|
|
||||||
- VI 발동 → 해당 종목 즉시 청산
|
|
||||||
- 호가 스프레드 > 0.5% → 진입 금지
|
|
||||||
- 매수 후 5분 미체결 → 주문 취소
|
|
||||||
- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산
|
|
||||||
- API 오류 10건/분 → kill-switch 자동 실행
|
|
||||||
- Claude API 오류 → daily_context 없을 시 보수적 기본값으로 fallback
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Claude API 장애 시 fallback
|
|
||||||
DEFAULT_CONTEXT = {
|
|
||||||
"trade_allowed": True,
|
|
||||||
"market_sentiment": "중립",
|
|
||||||
"sentiment_score": 50,
|
|
||||||
"risk_level": "보통",
|
|
||||||
"hot_sectors": [],
|
|
||||||
"avoid_sectors": [],
|
|
||||||
"boosted_tickers": [],
|
|
||||||
"blacklist_tickers": [],
|
|
||||||
"position_size_multiplier": 0.8, # 보수적으로 축소
|
|
||||||
"reason": "AI 판단 실패 - 기본값 적용"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. KIS API 활용 전체 목록 (v2.0)
|
|
||||||
|
|
||||||
### 기존 (실행 레이어)
|
|
||||||
| API | 용도 | 방식 |
|
|
||||||
|-----|------|------|
|
|
||||||
| H0STCNT0 | 실시간 체결가 | WebSocket |
|
|
||||||
| H0STASP0 | 실시간 호가 | WebSocket |
|
|
||||||
| H0STVI0 | VI 발동/해제 | WebSocket |
|
|
||||||
| TTTC0802U | 주식 매수 주문 | REST POST |
|
|
||||||
| TTTC0801U | 주식 매도 주문 | REST POST |
|
|
||||||
| TTTC8001R | 잔고 조회 | REST GET |
|
|
||||||
|
|
||||||
### 신규 (AI 판단 레이어)
|
|
||||||
| API | 용도 | 수집 시각 |
|
|
||||||
|-----|------|---------|
|
|
||||||
| FHKST01010100 | 종목 현재가 (KOSPI/KOSDAQ 지수) | 07:40 |
|
|
||||||
| FHKST03010100 | 업종별 등락률 | 07:40 |
|
|
||||||
| FHPST01710000 | 거래량 순위 상위 30 | 07:40 |
|
|
||||||
| FHPST01700000 | 등락률 순위 상위 30 | 07:40 |
|
|
||||||
| FHKST04430000 | 외국인/기관 순매수 가집계 | 07:40 |
|
|
||||||
|
|
||||||
> 위 API 호출은 모두 장 시작 전 단 1회 → rate limit 부담 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 스케줄 타임라인 (v2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
07:30 │ [AI] 네이버 금융 뉴스 크롤링 (전일 헤드라인 수집)
|
|
||||||
07:40 │ [AI] KIS REST API → 수급/순위/업종 데이터 수집
|
|
||||||
08:00 │ [AI] Claude API 호출 → daily_context.json 생성
|
|
||||||
│ 텔레그램 알림: "[AI분석] 오늘 시장: 중립 / 반도체 주목"
|
|
||||||
08:30 │ Universe 갱신 + AI 블랙리스트 적용
|
|
||||||
08:50 │ WebSocket 연결, 30종목 구독
|
|
||||||
│ 목표가 계산 (변동성 돌파)
|
|
||||||
09:00 │ ─────────── 진입 허용 시작 ───────────
|
|
||||||
│ 1초 단위 asyncio 루프 시작
|
|
||||||
11:30 │ 신규 진입 중단 (점심)
|
|
||||||
13:00 │ 신규 진입 재개
|
|
||||||
14:30 │ 신규 진입 마감
|
|
||||||
14:50 │ ─────────── 강제 청산 ───────────────
|
|
||||||
15:00 │ WebSocket 종료
|
|
||||||
15:10 │ 당일 결산 로그
|
|
||||||
15:20 │ 텔레그램 일일 결산 알림
|
|
||||||
15:30 │ 대기
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. DB 스키마 (SQLite, v2.0)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기존 체결 내역
|
|
||||||
CREATE TABLE trades (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
ticker TEXT NOT NULL,
|
|
||||||
entry_time TEXT NOT NULL,
|
|
||||||
exit_time TEXT,
|
|
||||||
entry_price REAL NOT NULL,
|
|
||||||
exit_price REAL,
|
|
||||||
quantity INTEGER NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
exit_reason TEXT,
|
|
||||||
pnl REAL,
|
|
||||||
fee REAL,
|
|
||||||
slippage REAL,
|
|
||||||
strategy TEXT DEFAULT 'VB',
|
|
||||||
ai_boosted INTEGER DEFAULT 0 -- [신규] AI boosted 여부
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 기존 일일 요약
|
|
||||||
CREATE TABLE daily_summary (
|
|
||||||
date TEXT PRIMARY KEY,
|
|
||||||
total_trades INTEGER,
|
|
||||||
win_trades INTEGER,
|
|
||||||
lose_trades INTEGER,
|
|
||||||
gross_pnl REAL,
|
|
||||||
total_fee REAL,
|
|
||||||
net_pnl REAL,
|
|
||||||
max_drawdown REAL,
|
|
||||||
trading_stopped INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 포지션 (장중 현황)
|
|
||||||
CREATE TABLE positions (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
entry_time TEXT,
|
|
||||||
entry_price REAL,
|
|
||||||
quantity INTEGER,
|
|
||||||
tp1_done INTEGER DEFAULT 0,
|
|
||||||
target_price REAL,
|
|
||||||
stop_price REAL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- [신규] AI 판단 이력
|
|
||||||
CREATE TABLE ai_context_log (
|
|
||||||
date TEXT PRIMARY KEY,
|
|
||||||
generated_at TEXT,
|
|
||||||
trade_allowed INTEGER,
|
|
||||||
market_sentiment TEXT,
|
|
||||||
sentiment_score INTEGER,
|
|
||||||
risk_level TEXT,
|
|
||||||
hot_sectors TEXT, -- JSON 배열
|
|
||||||
avoid_sectors TEXT, -- JSON 배열
|
|
||||||
boosted_tickers TEXT, -- JSON 배열
|
|
||||||
blacklist_tickers TEXT, -- JSON 배열
|
|
||||||
position_size_mult REAL,
|
|
||||||
reason TEXT,
|
|
||||||
claude_tokens_used INTEGER,
|
|
||||||
api_call_success INTEGER DEFAULT 1
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 알림 설계 (텔레그램, v2.0)
|
|
||||||
|
|
||||||
| 이벤트 | 메시지 형식 |
|
|
||||||
|--------|-----------|
|
|
||||||
| AI 분석 완료 | `[AI분석] 시장: 중립(62점) / 주목: 반도체,2차전지 / 회피: 금융` |
|
|
||||||
| AI 진입 차단 | `[AI차단] 삼성전자 진입 차단 - 금융 섹터 회피` |
|
|
||||||
| 매수 (일반) | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` |
|
|
||||||
| 매수 (AI부스트) | `[매수★] 하이닉스 185,000원 / AI 추천 종목` |
|
|
||||||
| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` |
|
|
||||||
| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` |
|
|
||||||
| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` |
|
|
||||||
| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` |
|
|
||||||
| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2% / AI 정확도: 3/5` |
|
|
||||||
| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` |
|
|
||||||
| AI 실패 | `[경고] AI 판단 실패. 기본값 적용 (비중 80%).` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 비용 분석 (월간)
|
|
||||||
|
|
||||||
### Claude API 토큰 사용량 추정
|
|
||||||
| 작업 | 빈도 | 토큰/회 | 월간 토큰 |
|
|
||||||
|------|------|---------|---------|
|
|
||||||
| 일일 시장 판단 | 1회/일 × 22거래일 | ~3,000 | ~66,000 |
|
|
||||||
| fallback 재시도 | 0~2회/월 | ~3,000 | ~6,000 |
|
|
||||||
| **합계** | | | **~72,000 토큰/월** |
|
|
||||||
|
|
||||||
**월 비용 (Claude Sonnet 4): 약 $2~3 (한화 약 3,000~4,000원)**
|
|
||||||
|
|
||||||
### 전체 운영 비용
|
|
||||||
| 항목 | 월 비용 |
|
|
||||||
|------|--------|
|
|
||||||
| KIS API | 무료 |
|
|
||||||
| Claude API | ~$3 |
|
|
||||||
| 네이버 크롤링 | 무료 |
|
|
||||||
| Synology NAS 전기세 | 기존 운영 중이면 추가 없음 |
|
|
||||||
| **합계** | **~$3/월** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 백테스트 계획 (v2.0)
|
|
||||||
|
|
||||||
### AI 판단 레이어 검증 방법
|
|
||||||
AI 판단은 백테스트에 직접 포함 불가 (과거 뉴스 재현 어려움).
|
|
||||||
따라서 2단계로 분리 검증:
|
|
||||||
|
|
||||||
```
|
|
||||||
1단계: 규칙 기반 백테스트 (기존)
|
|
||||||
- 변동성 돌파 전략 단독 성과 측정
|
|
||||||
- K값 최적화, 수수료/슬리피지 반영
|
|
||||||
|
|
||||||
2단계: AI 필터 사후 분석 (모의투자 후)
|
|
||||||
- 3개월 모의투자 후 daily_context.json 이력 vs 실제 결과 대조
|
|
||||||
- AI가 "avoid" 판단한 날 손실률 vs "trade_allowed" 날 수익률 비교
|
|
||||||
- AI 판단 정확도 계산 → 임계값 재조정
|
|
||||||
```
|
|
||||||
|
|
||||||
### 합격 기준 (out-of-sample)
|
|
||||||
| 지표 | 통과 기준 | 목표 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 샤프지수 | > 1.0 | > 1.5 |
|
|
||||||
| MDD | < 15% | < 10% |
|
|
||||||
| 승률 | > 45% | > 55% |
|
|
||||||
| 손익비 | > 1.3 | > 1.8 |
|
|
||||||
| AI 차단 정확도 | > 55% | > 65% |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 인프라 (docker-compose.yml)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: stockbot-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./data/redis:/data
|
|
||||||
|
|
||||||
stockbot:
|
|
||||||
build: ./app
|
|
||||||
container_name: stockbot-main
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Seoul
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
|
|
||||||
kill-switch:
|
|
||||||
build: ./kill_switch
|
|
||||||
container_name: stockbot-killswitch
|
|
||||||
restart: "no"
|
|
||||||
env_file: .env
|
|
||||||
profiles: ["emergency"]
|
|
||||||
|
|
||||||
dashboard:
|
|
||||||
build: ./monitor
|
|
||||||
container_name: stockbot-dashboard
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8501:8501"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Seoul
|
|
||||||
```
|
|
||||||
|
|
||||||
### .env 구조 (v2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
# KIS API
|
|
||||||
KIS_APP_KEY=...
|
|
||||||
KIS_APP_SECRET=...
|
|
||||||
KIS_ACCOUNT_NO=...
|
|
||||||
KIS_MOCK=true
|
|
||||||
|
|
||||||
# Claude API (신규)
|
|
||||||
ANTHROPIC_API_KEY=...
|
|
||||||
CLAUDE_MODEL=claude-sonnet-4-20250514
|
|
||||||
|
|
||||||
# 텔레그램
|
|
||||||
TELEGRAM_TOKEN=...
|
|
||||||
TELEGRAM_CHAT_ID=...
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_HOST=stockbot-redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# 운영 모드
|
|
||||||
DRY_RUN=true
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
AI_ENABLED=true # false 시 AI 레이어 비활성화, 기본값 사용
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. 개발 로드맵 (v2.0)
|
|
||||||
|
|
||||||
### Phase 1 — 데이터 기반 구축 (2주)
|
|
||||||
- [ ] KIS Open API 신청 + 모의투자 계좌
|
|
||||||
- [ ] pykrx 분봉 데이터 백필 (2021~2024)
|
|
||||||
- [ ] SQLite 스키마 (v2.0 포함)
|
|
||||||
- [ ] Universe 스캐너 구현
|
|
||||||
- [ ] Docker Compose NAS 기동 확인
|
|
||||||
|
|
||||||
### Phase 2 — 백테스트 (3주)
|
|
||||||
- [ ] vectorbt 변동성 돌파 백테스트
|
|
||||||
- [ ] 수수료/슬리피지 반영
|
|
||||||
- [ ] K값 그리드 서치 + out-of-sample 검증
|
|
||||||
|
|
||||||
### Phase 3 — KIS 연동 + dry-run (2주)
|
|
||||||
- [ ] KIS REST/WebSocket 클라이언트
|
|
||||||
- [ ] OrderExecutor (시장가/지정가/IOC)
|
|
||||||
- [ ] DRY_RUN=true 신호 발생 확인
|
|
||||||
|
|
||||||
### Phase 4 — AI 레이어 구현 (2주) ← 신규
|
|
||||||
- [ ] 네이버 금융 뉴스 크롤러 구현
|
|
||||||
- [ ] KIS 순위/수급 API 수집 모듈
|
|
||||||
- [ ] Claude API 연동 + 프롬프트 튜닝
|
|
||||||
- [ ] daily_context.json 생성 파이프라인
|
|
||||||
- [ ] fallback 로직 구현
|
|
||||||
- [ ] ai_context_log DB 저장
|
|
||||||
|
|
||||||
### Phase 5 — 리스크 + 알림 + 대시보드 (2주)
|
|
||||||
- [ ] RiskManager L1~L5 구현
|
|
||||||
- [ ] kill-switch 컨테이너
|
|
||||||
- [ ] 텔레그램 Notifier (AI 메시지 포함)
|
|
||||||
- [ ] Streamlit 대시보드 (AI 판단 현황 패널 추가)
|
|
||||||
|
|
||||||
### Phase 6 — 모의투자 실운영 (최소 3개월)
|
|
||||||
- [ ] KIS_MOCK=true, DRY_RUN=false
|
|
||||||
- [ ] 매일 결산 로그 + AI 정확도 추적
|
|
||||||
- [ ] 3개월 후 AI 필터 임계값 재조정
|
|
||||||
- [ ] 샤프 > 1.0, MDD < 15% 달성 시 Phase 7
|
|
||||||
|
|
||||||
### Phase 7 — 소액 실거래 (무기한)
|
|
||||||
- [ ] KIS_MOCK=false
|
|
||||||
- [ ] 총자산 5% 한도로 시작
|
|
||||||
- [ ] 1개월 단위 성과 검토
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. 보안 체크리스트
|
|
||||||
|
|
||||||
- [ ] .env → .gitignore 등록
|
|
||||||
- [ ] KIS/Anthropic API 키 → GitHub 절대 금지
|
|
||||||
- [ ] ANTHROPIC_API_KEY 월 1회 암호화 백업
|
|
||||||
- [ ] Synology 방화벽: 8501 내부망만 허용
|
|
||||||
- [ ] 텔레그램 봇: chat_id 화이트리스트
|
|
||||||
- [ ] 주문 로그 SQLite + logs/ 이중 보관
|
|
||||||
- [ ] AI 판단 이력 ai_context_log 영구 보관
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 절대 금지 (하드코딩)
|
|
||||||
|
|
||||||
```python
|
|
||||||
BLACKLIST_REASONS = [
|
|
||||||
"신규상장 6개월 미만", "관리종목", "투자경고",
|
|
||||||
"거래정지", "우선주", "스팩", "ETF/ETN",
|
|
||||||
]
|
|
||||||
|
|
||||||
TRADING_BLACKOUT = [
|
|
||||||
("11:30", "13:00"), # 점심
|
|
||||||
("14:50", "15:30"), # 마감
|
|
||||||
("08:00", "09:00"), # 동시호가
|
|
||||||
]
|
|
||||||
|
|
||||||
HARD_EXIT_TIME = "14:50" # 절대 변경 불가
|
|
||||||
|
|
||||||
# AI가 trade_allowed=false 반환해도 이미 보유 중인 포지션 청산은 진행
|
|
||||||
# AI는 신규 진입만 차단, 청산 로직에는 관여하지 않음
|
|
||||||
AI_SCOPE = "ENTRY_ONLY" # 절대 변경 불가
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. 면책 조항
|
|
||||||
|
|
||||||
> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다.
|
|
||||||
> AI 판단 레이어는 보조 필터일 뿐, 수익을 보장하지 않는다.
|
|
||||||
> 단타는 개인투자자의 90% 이상이 손실을 보는 영역이다.
|
|
||||||
> 반드시 모의투자 3개월 이상 검증 후 실거래 전환할 것.
|
|
||||||
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1021 KiB |