Add complete Jaryo File Manager with Synology NAS deployment support
This commit is contained in:
@@ -10,9 +10,39 @@
|
|||||||
"WebFetch(domain:developers.cloudflare.com)",
|
"WebFetch(domain:developers.cloudflare.com)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(cp:*)"
|
"Bash(cp:*)",
|
||||||
|
"Bash(npm run init-db:*)",
|
||||||
|
"Bash(npm start)",
|
||||||
|
"Bash(set PORT=3001)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(mv:*)",
|
||||||
|
"Bash(true)",
|
||||||
|
"Bash(PORT=3001 npm start)",
|
||||||
|
"Bash(sqlite3:*)",
|
||||||
|
"Bash(PORT=3002 npm start)",
|
||||||
|
"Bash(taskkill:*)",
|
||||||
|
"Bash(rm:*)",
|
||||||
|
"mcp__playwright__browser_navigate",
|
||||||
|
"mcp__playwright__browser_type",
|
||||||
|
"mcp__playwright__browser_click",
|
||||||
|
"mcp__playwright__browser_snapshot",
|
||||||
|
"mcp__playwright__browser_handle_dialog",
|
||||||
|
"mcp__playwright__browser_close",
|
||||||
|
"mcp__playwright__browser_console_messages",
|
||||||
|
"mcp__playwright__browser_press_key",
|
||||||
|
"mcp__playwright__browser_file_upload",
|
||||||
|
"mcp__playwright__browser_select_option",
|
||||||
|
"Bash(tasklist)",
|
||||||
|
"Bash(start http://localhost:8000)",
|
||||||
|
"Bash(npm --version)",
|
||||||
|
"mcp__sequential-thinking__sequentialthinking"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": [],
|
||||||
}
|
"additionalDirectories": [
|
||||||
|
"C:\\c\\Users\\COMTREE\\claude_code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default-mode": "plan"
|
||||||
}
|
}
|
151
.gitignore
vendored
Normal file
151
.gitignore
vendored
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Grunt intermediate storage
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Upload files (production)
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
BIN
.playwright-mcp/-.hwp
Normal file
BIN
.playwright-mcp/-.hwp
Normal file
Binary file not shown.
BIN
.playwright-mcp/-.zip
Normal file
BIN
.playwright-mcp/-.zip
Normal file
Binary file not shown.
Binary file not shown.
BIN
.playwright-mcp/-6-.png
Normal file
BIN
.playwright-mcp/-6-.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
BIN
.playwright-mcp/20250807-084242.jpg
Normal file
BIN
.playwright-mcp/20250807-084242.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 MiB |
BIN
.playwright-mcp/문화체험관-다도체험-운영.zip
Normal file
BIN
.playwright-mcp/문화체험관-다도체험-운영.zip
Normal file
Binary file not shown.
260
admin/api-client.js
Normal file
260
admin/api-client.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// 관리자용 API 클라이언트
|
||||||
|
// SQLite 백엔드와 통신하는 함수들
|
||||||
|
|
||||||
|
const API_BASE_URL = '';
|
||||||
|
|
||||||
|
// API 요청 헬퍼 함수
|
||||||
|
async function apiRequest(url, options = {}) {
|
||||||
|
const fullUrl = `${API_BASE_URL}${url}`;
|
||||||
|
console.log('🌐 API 요청:', options.method || 'GET', fullUrl);
|
||||||
|
console.log('요청 옵션:', options);
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
|
credentials: 'include', // 세션 쿠키 포함
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📨 응답 받음:', response.status, response.statusText);
|
||||||
|
console.log('응답 URL:', response.url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error('❌ API 오류 응답:', error);
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 관련 API
|
||||||
|
const AuthAPI = {
|
||||||
|
// 현재 세션 확인
|
||||||
|
async getSession() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/auth/session');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 확인 오류:', error);
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
async login(email, password) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('로그인 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await apiRequest('/api/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('로그아웃 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 관리 API
|
||||||
|
const FilesAPI = {
|
||||||
|
// 모든 파일 조회 (관리자용)
|
||||||
|
async getAll() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/files');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 목록 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 공개 파일 조회 (일반 사용자용)
|
||||||
|
async getPublic() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/files/public');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공개 파일 목록 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 파일 추가
|
||||||
|
async create(formData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData // FormData는 Content-Type 헤더를 자동 설정
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 추가 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 파일 수정 (FormData 지원)
|
||||||
|
async update(id, data) {
|
||||||
|
try {
|
||||||
|
let requestOptions;
|
||||||
|
|
||||||
|
if (data instanceof FormData) {
|
||||||
|
// FormData인 경우 (파일 업로드 포함)
|
||||||
|
requestOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
body: data // FormData는 Content-Type 헤더를 자동 설정
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📁 FormData를 사용한 파일 수정 요청');
|
||||||
|
const response = await fetch(`/api/files/${id}`, requestOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} else {
|
||||||
|
// 일반 JSON 데이터인 경우
|
||||||
|
const response = await apiRequest(`/api/files/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 수정 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 파일 삭제
|
||||||
|
async delete(id) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/api/files/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
async download(fileId, attachmentId) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/download/${fileId}/${attachmentId}`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 관리 API
|
||||||
|
const CategoriesAPI = {
|
||||||
|
// 모든 카테고리 조회
|
||||||
|
async getAll() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/categories');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 목록 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 카테고리 추가
|
||||||
|
async create(name) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 추가 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 카테고리 수정
|
||||||
|
async update(id, name) {
|
||||||
|
try {
|
||||||
|
const url = `/api/categories/${id}`;
|
||||||
|
console.log('🔄 카테고리 수정 API 호출:', url);
|
||||||
|
console.log('전송 데이터:', { id, name });
|
||||||
|
|
||||||
|
const response = await apiRequest(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('API 응답 상태:', response.status);
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('API 응답 데이터:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 수정 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 카테고리 삭제
|
||||||
|
async delete(id) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/api/categories/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 삭제 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결 테스트 API
|
||||||
|
const SystemAPI = {
|
||||||
|
// 서버 연결 테스트
|
||||||
|
async testConnection() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('연결 테스트 오류:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전역으로 내보내기
|
||||||
|
window.AdminAPI = {
|
||||||
|
Auth: AuthAPI,
|
||||||
|
Files: FilesAPI,
|
||||||
|
Categories: CategoriesAPI,
|
||||||
|
System: SystemAPI
|
||||||
|
};
|
199
admin/index.html
199
admin/index.html
@@ -3,29 +3,50 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>자료실 - CRUD 시스템</title>
|
<title>자료실 - 관리자</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>📚 자료실 관리 시스템</h1>
|
<h1>📚 자료실 관리자</h1>
|
||||||
<p>파일과 문서를 효율적으로 관리하세요</p>
|
<p>관리자 전용 페이지입니다</p>
|
||||||
<div id="authSection" class="auth-section">
|
|
||||||
<div id="authButtons" class="auth-buttons" style="display: none;">
|
<!-- 로그인 폼 -->
|
||||||
<button id="loginBtn" class="auth-btn">🔑 로그인</button>
|
<div id="loginSection" class="login-section">
|
||||||
<button id="signupBtn" class="auth-btn">👤 회원가입</button>
|
<div class="login-form">
|
||||||
|
<h3>🔐 관리자 로그인</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="email" id="adminEmail" placeholder="이메일" value="admin@jaryo.com" required>
|
||||||
</div>
|
</div>
|
||||||
<div id="userInfo" class="user-info" style="display: flex;">
|
<div class="form-group">
|
||||||
<span id="userEmail">오프라인 사용자</span>
|
<input type="password" id="adminPassword" placeholder="비밀번호" required>
|
||||||
<span id="syncStatus" class="sync-status offline">🟡 오프라인</span>
|
</div>
|
||||||
<button id="logoutBtn" class="auth-btn" style="display: none;">🚪 로그아웃</button>
|
<button id="loginBtn" class="login-btn">로그인</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="public-link">
|
||||||
|
<a href="/" class="public-btn">👥 일반 자료실 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 로그인 후 표시될 관리자 정보 -->
|
||||||
|
<div id="adminSection" class="admin-section" style="display: none;">
|
||||||
|
<div class="admin-info">
|
||||||
|
<span id="adminUserEmail">관리자</span>
|
||||||
|
<span id="connectionStatus" class="connection-status online">🟢 온라인</span>
|
||||||
|
<button id="logoutBtn" class="logout-btn">🚪 로그아웃</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="public-link">
|
||||||
|
<a href="/" class="public-btn">👥 일반 자료실 보기</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 관리자 전용 영역 (로그인 후에만 표시) -->
|
||||||
|
<div id="adminPanel" style="display: none;">
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
|
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
|
||||||
<select id="categoryFilter">
|
<select id="categoryFilter">
|
||||||
@@ -40,6 +61,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
<div class="section-tabs">
|
||||||
|
<button class="tab-btn active" id="fileTabBtn">📁 자료 관리</button>
|
||||||
|
<button class="tab-btn" id="categoryTabBtn">🏷️ 카테고리 관리</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileTab" class="tab-content active">
|
||||||
<h2>📁 새 자료 추가</h2>
|
<h2>📁 새 자료 추가</h2>
|
||||||
<form id="fileForm">
|
<form id="fileForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -67,7 +94,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fileUpload">파일 첨부 (여러 파일 선택 가능)</label>
|
<label for="fileUpload">파일 첨부 (여러 파일 선택 가능)</label>
|
||||||
<div class="file-upload-area" id="fileUploadArea">
|
<div class="file-upload-area" id="fileUploadArea">
|
||||||
<input type="file" id="fileUpload" multiple accept="*/*">
|
<input type="file" id="fileUpload" multiple accept="*/*" style="display: none;">
|
||||||
<div class="upload-placeholder">
|
<div class="upload-placeholder">
|
||||||
<div class="upload-icon">📁</div>
|
<div class="upload-icon">📁</div>
|
||||||
<p><strong>파일을 여기로 드래그하거나 클릭하여 선택하세요</strong></p>
|
<p><strong>파일을 여기로 드래그하거나 클릭하여 선택하세요</strong></p>
|
||||||
@@ -90,6 +117,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="categoryTab" class="tab-content">
|
||||||
|
<h2>🏷️ 카테고리 관리</h2>
|
||||||
|
<form id="categoryForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="categoryName">카테고리 이름 *</label>
|
||||||
|
<input type="text" id="categoryName" required placeholder="새 카테고리 이름">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit" id="addCategoryBtn">➕ 카테고리 추가</button>
|
||||||
|
<button type="button" id="cancelCategoryBtn">❌ 취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="category-list-section">
|
||||||
|
<h3>📋 현재 카테고리</h3>
|
||||||
|
<div class="category-list" id="categoryList">
|
||||||
|
<!-- 카테고리 목록이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="list-section">
|
<div class="list-section">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<h2>📋 자료 목록</h2>
|
<h2>📋 자료 목록</h2>
|
||||||
@@ -128,40 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- adminPanel 끝 -->
|
||||||
|
|
||||||
<!-- 인증 모달 -->
|
|
||||||
<div id="authModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h2 id="authModalTitle">🔑 로그인</h2>
|
|
||||||
<form id="authForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authEmail">이메일 *</label>
|
|
||||||
<input type="email" id="authEmail" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authPassword">비밀번호 *</label>
|
|
||||||
<input type="password" id="authPassword" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="confirmPasswordGroup" style="display: none;">
|
|
||||||
<label for="authConfirmPassword">비밀번호 확인 *</label>
|
|
||||||
<input type="password" id="authConfirmPassword">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-buttons">
|
|
||||||
<button type="submit" id="authSubmitBtn">🔑 로그인</button>
|
|
||||||
<button type="button" id="authCancelBtn">❌ 취소</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-switch">
|
|
||||||
<p id="authSwitchText">계정이 없으신가요? <a href="#" id="authSwitchLink">회원가입하기</a></p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div id="authLoading" class="loading" style="display: none;">
|
|
||||||
<p>처리 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,6 +213,50 @@
|
|||||||
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
|
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 첨부파일 관리 섹션 -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📎 첨부파일 관리</label>
|
||||||
|
<div class="attachment-management">
|
||||||
|
<!-- 기존 첨부파일 목록 -->
|
||||||
|
<div class="existing-attachments-section">
|
||||||
|
<h4 class="section-title">🗂️ 기존 첨부파일</h4>
|
||||||
|
<div class="existing-attachments" id="existingAttachments">
|
||||||
|
<div class="no-existing-files" id="noExistingFiles">
|
||||||
|
<span class="placeholder-text">📂 기존 첨부파일이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 새 파일 추가 섹션 -->
|
||||||
|
<div class="new-attachments-section">
|
||||||
|
<h4 class="section-title">➕ 새 파일 추가</h4>
|
||||||
|
|
||||||
|
<!-- 드래그&드롭 영역 -->
|
||||||
|
<div class="file-drop-zone" id="fileDropZone">
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
<div class="drop-zone-icon">📁</div>
|
||||||
|
<div class="drop-zone-text">
|
||||||
|
<p><strong>파일을 여기로 드래그하세요</strong></p>
|
||||||
|
<p class="or-text">또는</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="file-select-btn" id="fileSelectBtn">
|
||||||
|
📂 파일 선택
|
||||||
|
</button>
|
||||||
|
<input type="file" id="newAttachments" multiple accept="*/*" hidden>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-hint">
|
||||||
|
<small>여러 파일을 동시에 선택할 수 있습니다</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 선택된 새 파일 미리보기 -->
|
||||||
|
<div class="new-files-preview" id="newFilesPreview">
|
||||||
|
<!-- 선택된 파일들이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-buttons">
|
<div class="form-buttons">
|
||||||
<button type="submit">💾 저장</button>
|
<button type="submit">💾 저장</button>
|
||||||
<button type="button" id="closeModal">❌ 취소</button>
|
<button type="button" id="closeModal">❌ 취소</button>
|
||||||
@@ -204,7 +265,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="supabase-config.js"></script>
|
<!-- 카테고리 수정 모달 -->
|
||||||
|
<div id="editCategoryModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>✏️ 카테고리 수정</h2>
|
||||||
|
<form id="editCategoryForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editCategoryName">카테고리 이름 *</label>
|
||||||
|
<input type="text" id="editCategoryName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">💾 저장</button>
|
||||||
|
<button type="button" id="closeCategoryModal">❌ 취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="api-client.js"></script>
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 페이지 로드 완료 후 디버깅 정보 출력
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log('✅ DOM 로드 완료');
|
||||||
|
console.log('📋 fileList 요소:', document.getElementById('fileList'));
|
||||||
|
console.log('📋 pagination 요소:', document.getElementById('pagination'));
|
||||||
|
|
||||||
|
// 3초 후 파일 매니저 상태 확인
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.fileManager) {
|
||||||
|
console.log('📋 FileManager 인스턴스:', window.fileManager);
|
||||||
|
console.log('📋 파일 개수:', window.fileManager.files?.length || 0);
|
||||||
|
console.log('📋 현재 사용자:', window.fileManager.currentUser);
|
||||||
|
|
||||||
|
// 강제로 다시 렌더링 시도
|
||||||
|
if (window.fileManager.files && window.fileManager.files.length > 0) {
|
||||||
|
console.log('🔄 파일 목록 강제 재렌더링...');
|
||||||
|
window.fileManager.renderFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
2672
admin/script.js
2672
admin/script.js
File diff suppressed because it is too large
Load Diff
1298
admin/styles.css
1298
admin/styles.css
File diff suppressed because it is too large
Load Diff
50
api-client.js
Normal file
50
api-client.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// 일반 사용자용 API 클라이언트
|
||||||
|
// SQLite 백엔드와 통신하는 함수들
|
||||||
|
|
||||||
|
const API_BASE_URL = '';
|
||||||
|
|
||||||
|
// API 요청 헬퍼 함수
|
||||||
|
async function apiRequest(url, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공개 파일 목록 조회
|
||||||
|
async function getPublicFiles() {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/files/public');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공개 파일 목록 조회 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
async function downloadFile(fileId, attachmentId) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/download/${fileId}/${attachmentId}`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역으로 내보내기
|
||||||
|
window.ApiClient = {
|
||||||
|
getPublicFiles,
|
||||||
|
downloadFile
|
||||||
|
};
|
5
cookies.txt
Normal file
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 1755826306 connect.sid s%3Alkct8oX-9zlTMoD6Mu3BP0RwCz0CFR-X.Mad5GaxzMugYjbnKEOxUq9mnbJkkJY3O79f3q%2BaE%2BJ4
|
576
database/db-helper.js
Normal file
576
database/db-helper.js
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
class DatabaseHelper {
|
||||||
|
constructor() {
|
||||||
|
this.dbPath = path.join(__dirname, 'jaryo.db');
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스 연결
|
||||||
|
connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.db) {
|
||||||
|
resolve(this.db);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READWRITE, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('데이터베이스 연결 오류:', err.message);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log('✅ SQLite 데이터베이스 연결됨');
|
||||||
|
resolve(this.db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스 연결 종료
|
||||||
|
close() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
this.db = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 파일 목록 가져오기
|
||||||
|
async getAllFiles(limit = 100, offset = 0) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 더 간단한 쿼리로 변경 - 첨부파일은 별도 쿼리로 처리
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM files
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.db.all(query, [limit, offset], async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const file = {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||||
|
user_id: row.user_id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
files: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 각 파일의 첨부파일을 별도로 조회
|
||||||
|
try {
|
||||||
|
const attachments = await this.getFileAttachments(row.id);
|
||||||
|
file.files = attachments;
|
||||||
|
} catch (attachmentError) {
|
||||||
|
console.warn('첨부파일 조회 오류:', attachmentError);
|
||||||
|
file.files = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일의 첨부파일 목록 가져오기
|
||||||
|
async getFileAttachments(fileId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'SELECT * FROM file_attachments WHERE file_id = ?';
|
||||||
|
this.db.all(query, [fileId], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
const attachments = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
original_name: row.original_name,
|
||||||
|
file_name: row.file_name,
|
||||||
|
file_path: row.file_path,
|
||||||
|
file_size: row.file_size,
|
||||||
|
mime_type: row.mime_type,
|
||||||
|
name: row.original_name, // 호환성을 위해
|
||||||
|
size: row.file_size // 호환성을 위해
|
||||||
|
}));
|
||||||
|
resolve(attachments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 검색
|
||||||
|
async searchFiles(searchTerm, category = null, limit = 100) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let query = `
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
query += ' AND category = ?';
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC LIMIT ?';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
this.db.all(query, params, async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const file = {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||||
|
user_id: row.user_id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
files: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 각 파일의 첨부파일을 별도로 조회
|
||||||
|
try {
|
||||||
|
const attachments = await this.getFileAttachments(row.id);
|
||||||
|
file.files = attachments;
|
||||||
|
} catch (attachmentError) {
|
||||||
|
console.warn('첨부파일 조회 오류:', attachmentError);
|
||||||
|
file.files = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 파일 추가
|
||||||
|
async addFile(fileData) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO files (id, title, description, category, tags, user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
fileData.id || this.generateId(),
|
||||||
|
fileData.title,
|
||||||
|
fileData.description || '',
|
||||||
|
fileData.category,
|
||||||
|
JSON.stringify(fileData.tags || []),
|
||||||
|
fileData.user_id || 'offline-user'
|
||||||
|
];
|
||||||
|
|
||||||
|
this.db.run(query, params, function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: params[0], changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 정보 수정
|
||||||
|
async updateFile(id, updates) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const setClause = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (updates.title !== undefined) {
|
||||||
|
setClause.push('title = ?');
|
||||||
|
params.push(updates.title);
|
||||||
|
}
|
||||||
|
if (updates.description !== undefined) {
|
||||||
|
setClause.push('description = ?');
|
||||||
|
params.push(updates.description);
|
||||||
|
}
|
||||||
|
if (updates.category !== undefined) {
|
||||||
|
setClause.push('category = ?');
|
||||||
|
params.push(updates.category);
|
||||||
|
}
|
||||||
|
if (updates.tags !== undefined) {
|
||||||
|
setClause.push('tags = ?');
|
||||||
|
params.push(JSON.stringify(updates.tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
setClause.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const query = `UPDATE files SET ${setClause.join(', ')} WHERE id = ?`;
|
||||||
|
|
||||||
|
this.db.run(query, params, function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 삭제
|
||||||
|
async deleteFile(id) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 첨부파일부터 삭제 (CASCADE가 있지만 명시적으로)
|
||||||
|
this.db.run('DELETE FROM file_attachments WHERE file_id = ?', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 정보 삭제
|
||||||
|
this.db.run('DELETE FROM files WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 추가
|
||||||
|
async addFileAttachment(fileId, attachmentData) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type, file_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
fileId,
|
||||||
|
attachmentData.original_name,
|
||||||
|
attachmentData.file_name || attachmentData.original_name,
|
||||||
|
attachmentData.file_path || '',
|
||||||
|
attachmentData.file_size || 0,
|
||||||
|
attachmentData.mime_type || '',
|
||||||
|
attachmentData.file_data || null
|
||||||
|
];
|
||||||
|
|
||||||
|
this.db.run(query, params, function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: this.lastID, changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 삭제
|
||||||
|
async deleteFileAttachment(attachmentId) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'DELETE FROM file_attachments WHERE id = ?';
|
||||||
|
|
||||||
|
this.db.run(query, [attachmentId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 목록 가져오기
|
||||||
|
async getCategories() {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'SELECT * FROM categories ORDER BY is_default DESC, name ASC';
|
||||||
|
|
||||||
|
this.db.all(query, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 추가
|
||||||
|
async addCategory(name) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'INSERT INTO categories (name) VALUES (?)';
|
||||||
|
|
||||||
|
this.db.run(query, [name], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: this.lastID, changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 수정
|
||||||
|
async updateCategory(id, name) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'UPDATE categories SET name = ? WHERE id = ?';
|
||||||
|
|
||||||
|
this.db.run(query, [name, id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 삭제
|
||||||
|
async deleteCategory(id) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 해당 카테고리를 사용하는 파일들을 '기타'로 변경
|
||||||
|
this.db.serialize(() => {
|
||||||
|
this.db.run('UPDATE files SET category = "기타" WHERE category = (SELECT name FROM categories WHERE id = ?)', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 삭제
|
||||||
|
this.db.run('DELETE FROM categories WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 정보 가져오기
|
||||||
|
async getStats() {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const queries = [
|
||||||
|
'SELECT COUNT(*) as total_files FROM files',
|
||||||
|
'SELECT category, COUNT(*) as count FROM files GROUP BY category',
|
||||||
|
'SELECT COUNT(*) as total_attachments FROM file_attachments'
|
||||||
|
];
|
||||||
|
|
||||||
|
Promise.all(queries.map(query =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
this.db.all(query, [], (err, rows) => {
|
||||||
|
if (err) rej(err);
|
||||||
|
else res(rows);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)).then(results => {
|
||||||
|
resolve({
|
||||||
|
total_files: results[0][0].total_files,
|
||||||
|
by_category: results[1],
|
||||||
|
total_attachments: results[2][0].total_attachments
|
||||||
|
});
|
||||||
|
}).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 관련 메서드들
|
||||||
|
async getUserByEmail(email) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'SELECT * FROM users WHERE email = ? AND is_active = 1';
|
||||||
|
this.db.get(query, [email], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(id) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'SELECT * FROM users WHERE id = ? AND is_active = 1';
|
||||||
|
this.db.get(query, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(userData) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO users (id, email, password_hash, name, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userId = this.generateId();
|
||||||
|
const params = [
|
||||||
|
userId,
|
||||||
|
userData.email,
|
||||||
|
userData.password_hash,
|
||||||
|
userData.name,
|
||||||
|
userData.role || 'user'
|
||||||
|
];
|
||||||
|
|
||||||
|
this.db.run(query, params, function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: userId, changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserLastLogin(userId) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?';
|
||||||
|
this.db.run(query, [userId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(userId, sessionId, expiresAt) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO user_sessions (id, user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.db.run(query, [sessionId, userId, expiresAt], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ id: sessionId, changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionId) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
SELECT s.*, u.id as user_id, u.email, u.name, u.role
|
||||||
|
FROM user_sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.is_active = 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.db.get(query, [sessionId], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(sessionId) {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'DELETE FROM user_sessions WHERE id = ?';
|
||||||
|
this.db.run(query, [sessionId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanExpiredSessions() {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = 'DELETE FROM user_sessions WHERE expires_at <= datetime("now")';
|
||||||
|
this.db.run(query, [], function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({ changes: this.changes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID 생성 헬퍼
|
||||||
|
generateId() {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DatabaseHelper;
|
90
database/schema.sql
Normal file
90
database/schema.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
-- 자료실 SQLite 데이터베이스 스키마
|
||||||
|
-- 파일: database/schema.sql
|
||||||
|
|
||||||
|
-- 파일 정보 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT '기타',
|
||||||
|
tags TEXT, -- JSON 배열로 저장
|
||||||
|
user_id TEXT DEFAULT 'offline-user',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 파일 첨부 정보 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS file_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_id TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL, -- 실제 저장된 파일명
|
||||||
|
file_path TEXT NOT NULL, -- 파일 저장 경로
|
||||||
|
file_size INTEGER DEFAULT 0,
|
||||||
|
mime_type TEXT,
|
||||||
|
file_data TEXT, -- base64 인코딩된 파일 데이터 (소용량 파일용)
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 카테고리 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
is_default INTEGER DEFAULT 0, -- 기본 카테고리 여부
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 사용자 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT DEFAULT 'user', -- 'admin', 'user'
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 세션 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 기본 카테고리 데이터 삽입
|
||||||
|
INSERT OR IGNORE INTO categories (name, is_default) VALUES
|
||||||
|
('문서', 1),
|
||||||
|
('이미지', 1),
|
||||||
|
('동영상', 1),
|
||||||
|
('프레젠테이션', 1),
|
||||||
|
('기타', 1);
|
||||||
|
|
||||||
|
-- 기본 관리자 계정 생성 (비밀번호: admin123)
|
||||||
|
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES
|
||||||
|
('admin-001', 'admin@jaryo.com', '$2b$10$0u/zxn1NL4n6t.hNs1eMh.12tXEv9HYgf4cPRXKT3aX97mOKR01Du', '관리자', 'admin');
|
||||||
|
|
||||||
|
-- 샘플 데이터 삽입
|
||||||
|
INSERT OR IGNORE INTO files (id, title, description, category, tags, created_at, updated_at) VALUES
|
||||||
|
('sample-1', '프로젝트 계획서', '2024년 상반기 주요 프로젝트 계획서입니다.', '문서', '["계획서", "프로젝트", "2024"]', datetime('now'), datetime('now')),
|
||||||
|
('sample-2', '회의 자료', '월간 정기 회의 자료 모음입니다.', '프레젠테이션', '["회의", "정기회의"]', datetime('now'), datetime('now')),
|
||||||
|
('sample-3', '시스템 스크린샷', '새로운 관리 시스템 화면 캡처 이미지들입니다.', '이미지', '["시스템", "스크린샷"]', datetime('now'), datetime('now'));
|
||||||
|
|
||||||
|
-- 샘플 첨부파일 데이터
|
||||||
|
INSERT OR IGNORE INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type) VALUES
|
||||||
|
('sample-1', 'project_plan_2024.pdf', 'project_plan_2024.pdf', 'uploads/project_plan_2024.pdf', 1024000, 'application/pdf'),
|
||||||
|
('sample-2', 'meeting_slides.pptx', 'meeting_slides.pptx', 'uploads/meeting_slides.pptx', 2048000, 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
|
||||||
|
('sample-3', 'admin_screenshot1.png', 'admin_screenshot1.png', 'uploads/admin_screenshot1.png', 512000, 'image/png'),
|
||||||
|
('sample-3', 'admin_screenshot2.png', 'admin_screenshot2.png', 'uploads/admin_screenshot2.png', 768000, 'image/png');
|
||||||
|
|
||||||
|
-- 인덱스 생성
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_category ON files(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_file_attachments_file_id ON file_attachments(file_id);
|
||||||
|
|
68
debug-files.js
Normal file
68
debug-files.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const DatabaseHelper = require('./database/db-helper');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function debugFiles() {
|
||||||
|
const db = new DatabaseHelper();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.connect();
|
||||||
|
|
||||||
|
console.log('\n📋 데이터베이스의 모든 파일:');
|
||||||
|
const files = await db.getAllFiles();
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
console.log(`\n${index + 1}. ${file.title} (ID: ${file.id})`);
|
||||||
|
console.log(` 카테고리: ${file.category}`);
|
||||||
|
console.log(` 첨부파일: ${file.files?.length || 0}개`);
|
||||||
|
|
||||||
|
if (file.files && file.files.length > 0) {
|
||||||
|
file.files.forEach((attachment, idx) => {
|
||||||
|
console.log(` ${idx + 1}) ${attachment.original_name}`);
|
||||||
|
console.log(` - ID: ${attachment.id}`);
|
||||||
|
console.log(` - 경로: ${attachment.file_path}`);
|
||||||
|
console.log(` - 파일명: ${attachment.file_name}`);
|
||||||
|
console.log(` - 크기: ${attachment.file_size}`);
|
||||||
|
|
||||||
|
// 실제 파일 존재 확인
|
||||||
|
const fullPath = path.join(__dirname, attachment.file_path);
|
||||||
|
const exists = fs.existsSync(fullPath);
|
||||||
|
console.log(` - 실제 파일 존재: ${exists ? '✅' : '❌'} (${fullPath})`);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// 다른 경로들 시도
|
||||||
|
const paths = [
|
||||||
|
path.join(__dirname, 'uploads', attachment.file_name),
|
||||||
|
path.join(__dirname, 'uploads', attachment.original_name),
|
||||||
|
attachment.file_path,
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(` - 시도할 경로들:`);
|
||||||
|
paths.forEach(p => {
|
||||||
|
const pathExists = fs.existsSync(p);
|
||||||
|
console.log(` ${pathExists ? '✅' : '❌'} ${p}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n📁 uploads 폴더의 실제 파일들:');
|
||||||
|
const uploadsDir = path.join(__dirname, 'uploads');
|
||||||
|
if (fs.existsSync(uploadsDir)) {
|
||||||
|
const actualFiles = fs.readdirSync(uploadsDir);
|
||||||
|
actualFiles.forEach(file => {
|
||||||
|
const filePath = path.join(uploadsDir, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
console.log(` - ${file} (크기: ${stats.size})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 오류:', error.message);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugFiles();
|
104
deploy.sh
Normal file
104
deploy.sh
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Git을 통한 자동 배포 스크립트
|
||||||
|
# 사용법: ./deploy.sh [branch_name]
|
||||||
|
|
||||||
|
# 설정
|
||||||
|
PROJECT_DIR="/volume1/web/jaryo"
|
||||||
|
GIT_REPO="/volume1/git/jaryo-file-manager.git"
|
||||||
|
BACKUP_DIR="/volume1/web/jaryo-backup"
|
||||||
|
LOG_FILE="/volume1/web/jaryo/logs/deploy.log"
|
||||||
|
BRANCH=${1:-main}
|
||||||
|
|
||||||
|
# 로그 함수
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 로그 디렉토리 생성
|
||||||
|
mkdir -p "$(dirname $LOG_FILE)"
|
||||||
|
|
||||||
|
log "=== 배포 시작 ==="
|
||||||
|
log "브랜치: $BRANCH"
|
||||||
|
log "프로젝트 디렉토리: $PROJECT_DIR"
|
||||||
|
|
||||||
|
# 1. 현재 서비스 중지
|
||||||
|
log "기존 서비스 중지 중..."
|
||||||
|
if [ -f "$PROJECT_DIR/app.pid" ]; then
|
||||||
|
PID=$(cat "$PROJECT_DIR/app.pid")
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
kill "$PID"
|
||||||
|
sleep 3
|
||||||
|
log "서비스 중지 완료 (PID: $PID)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 백업 생성
|
||||||
|
log "현재 버전 백업 중..."
|
||||||
|
BACKUP_NAME="backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
if [ -d "$PROJECT_DIR" ]; then
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp -r "$PROJECT_DIR" "$BACKUP_DIR/$BACKUP_NAME"
|
||||||
|
log "백업 완료: $BACKUP_DIR/$BACKUP_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Git에서 최신 코드 가져오기
|
||||||
|
log "Git에서 최신 코드 가져오는 중..."
|
||||||
|
if [ ! -d "$PROJECT_DIR" ]; then
|
||||||
|
mkdir -p "$PROJECT_DIR"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
git clone "$GIT_REPO" .
|
||||||
|
else
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
# 현재 변경사항 백업
|
||||||
|
git stash push -m "Auto backup before deploy $(date)"
|
||||||
|
|
||||||
|
# 원격 저장소에서 최신 정보 가져오기
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# 지정된 브랜치로 체크아웃
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
|
||||||
|
# 원격 브랜치와 동기화
|
||||||
|
git pull origin "$BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. 의존성 설치
|
||||||
|
log "의존성 설치 중..."
|
||||||
|
npm install --production
|
||||||
|
|
||||||
|
# 5. 데이터베이스 마이그레이션 (필요한 경우)
|
||||||
|
log "데이터베이스 초기화 중..."
|
||||||
|
node scripts/init-database.js
|
||||||
|
|
||||||
|
# 6. 권한 설정
|
||||||
|
log "권한 설정 중..."
|
||||||
|
chmod +x *.sh
|
||||||
|
chown -R admin:users "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# 7. 서비스 시작
|
||||||
|
log "새로운 서비스 시작 중..."
|
||||||
|
./start-service.sh
|
||||||
|
|
||||||
|
# 8. 서비스 상태 확인
|
||||||
|
sleep 5
|
||||||
|
if [ -f "$PROJECT_DIR/app.pid" ]; then
|
||||||
|
PID=$(cat "$PROJECT_DIR/app.pid")
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
log "✅ 배포 성공! 서비스가 정상적으로 시작되었습니다. (PID: $PID)"
|
||||||
|
else
|
||||||
|
log "❌ 배포 실패! 서비스가 시작되지 않았습니다."
|
||||||
|
log "로그 확인: tail -f $PROJECT_DIR/logs/app.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "❌ 배포 실패! PID 파일이 생성되지 않았습니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9. 이전 백업 정리 (30일 이상 된 백업 삭제)
|
||||||
|
log "오래된 백업 정리 중..."
|
||||||
|
find "$BACKUP_DIR" -name "backup-*" -type d -mtime +30 -exec rm -rf {} \; 2>/dev/null
|
||||||
|
|
||||||
|
log "=== 배포 완료 ==="
|
||||||
|
log "서비스 URL: http://$(hostname -I | awk '{print $1}'):3005"
|
18
index.html
18
index.html
@@ -3,15 +3,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>자료실 - CRUD 시스템</title>
|
<title>자료실 - 파일 보기</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>📚 자료실 관리 시스템</h1>
|
<h1>📚 자료실</h1>
|
||||||
<p>파일과 문서를 효율적으로 관리하세요</p>
|
<p>등록된 자료를 검색하고 다운로드할 수 있습니다</p>
|
||||||
|
<div class="admin-link">
|
||||||
|
<a href="/admin/" class="admin-btn">🔑 관리자 페이지</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
@@ -27,7 +29,6 @@
|
|||||||
<button id="searchBtn">🔍 검색</button>
|
<button id="searchBtn">🔍 검색</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="list-section">
|
<div class="list-section">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<h2>📋 자료 목록</h2>
|
<h2>📋 자료 목록</h2>
|
||||||
@@ -66,10 +67,13 @@
|
|||||||
<button id="nextPage" class="page-btn" disabled>다음 ▶</button>
|
<button id="nextPage" class="page-btn" disabled>다음 ▶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingMessage" class="loading" style="display: none;">
|
||||||
|
<p>데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="api-client.js"></script>
|
||||||
<script src="supabase-config.js"></script>
|
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
3105
package-lock.json
generated
Normal file
3105
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "jaryo-file-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "자료실 파일 관리 시스템",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"init-db": "node scripts/init-database.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"path": "^0.12.7",
|
||||||
|
"fs": "^0.0.1-security",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
},
|
||||||
|
"keywords": ["file-manager", "sqlite", "express", "admin"],
|
||||||
|
"author": "Claude Code",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
25
pm2-ecosystem.config.js
Normal file
25
pm2-ecosystem.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'jaryo-file-manager',
|
||||||
|
script: 'server.js',
|
||||||
|
cwd: '/volume1/web/jaryo',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3005
|
||||||
|
},
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3005
|
||||||
|
},
|
||||||
|
log_file: '/volume1/web/jaryo/logs/combined.log',
|
||||||
|
out_file: '/volume1/web/jaryo/logs/out.log',
|
||||||
|
error_file: '/volume1/web/jaryo/logs/error.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
time: true
|
||||||
|
}]
|
||||||
|
};
|
51
pm2-start.sh
Normal file
51
pm2-start.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# PM2를 사용한 시놀로지 NAS 서비스 시작 스크립트
|
||||||
|
# 사용법: ./pm2-start.sh
|
||||||
|
|
||||||
|
PROJECT_DIR="/volume1/web/jaryo"
|
||||||
|
LOG_DIR="/volume1/web/jaryo/logs"
|
||||||
|
|
||||||
|
echo "=== PM2로 Jaryo File Manager 서비스 시작 ==="
|
||||||
|
|
||||||
|
# 로그 디렉토리 생성
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# 프로젝트 디렉토리로 이동
|
||||||
|
cd "$PROJECT_DIR" || {
|
||||||
|
echo "오류: 프로젝트 디렉토리를 찾을 수 없습니다: $PROJECT_DIR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# PM2 설치 확인 및 설치
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
echo "PM2 설치 중..."
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 의존성 설치 확인
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo "의존성 설치 중..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 데이터베이스 초기화
|
||||||
|
echo "데이터베이스 초기화 중..."
|
||||||
|
node scripts/init-database.js
|
||||||
|
|
||||||
|
# 기존 PM2 프로세스 중지
|
||||||
|
echo "기존 프로세스 정리 중..."
|
||||||
|
pm2 delete jaryo-file-manager 2>/dev/null || true
|
||||||
|
|
||||||
|
# PM2로 서비스 시작
|
||||||
|
echo "PM2로 서비스 시작 중..."
|
||||||
|
pm2 start pm2-ecosystem.config.js --env production
|
||||||
|
|
||||||
|
# PM2 시작 스크립트 생성
|
||||||
|
pm2 startup
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
echo "서비스가 PM2로 시작되었습니다."
|
||||||
|
echo "상태 확인: pm2 status"
|
||||||
|
echo "로그 확인: pm2 logs jaryo-file-manager"
|
||||||
|
echo "서비스 중지: pm2 stop jaryo-file-manager"
|
340
script.js
340
script.js
@@ -1,8 +1,6 @@
|
|||||||
class FileManager {
|
class PublicFileViewer {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.files = [];
|
this.files = [];
|
||||||
this.currentEditId = null;
|
|
||||||
this.isOnline = navigator.onLine;
|
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.itemsPerPage = 10;
|
this.itemsPerPage = 10;
|
||||||
this.filteredFiles = [];
|
this.filteredFiles = [];
|
||||||
@@ -11,16 +9,23 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// 오프라인 모드로만 실행
|
try {
|
||||||
this.files = this.loadFiles();
|
this.showLoading(true);
|
||||||
|
await this.loadFiles();
|
||||||
this.filteredFiles = [...this.files];
|
this.filteredFiles = [...this.files];
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.renderFiles();
|
this.renderFiles();
|
||||||
this.updatePagination();
|
this.updatePagination();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('초기화 오류:', error);
|
||||||
|
this.showNotification('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||||
|
} finally {
|
||||||
|
this.showLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
// 검색 및 정렬 이벤트만 유지
|
// 검색 및 정렬 이벤트
|
||||||
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
|
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
|
||||||
document.getElementById('searchInput').addEventListener('keyup', (e) => {
|
document.getElementById('searchInput').addEventListener('keyup', (e) => {
|
||||||
if (e.key === 'Enter') this.handleSearch();
|
if (e.key === 'Enter') this.handleSearch();
|
||||||
@@ -33,8 +38,20 @@ class FileManager {
|
|||||||
document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage());
|
document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
generateId() {
|
async loadFiles() {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
try {
|
||||||
|
const response = await fetch('/api/files/public');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
this.files = data.data || [];
|
||||||
|
console.log('파일 로드 완료:', this.files.length, '개');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 로드 오류:', error);
|
||||||
|
this.files = [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSearch() {
|
handleSearch() {
|
||||||
@@ -65,7 +82,7 @@ class FileManager {
|
|||||||
const fileList = document.getElementById('fileList');
|
const fileList = document.getElementById('fileList');
|
||||||
const sortBy = document.getElementById('sortBy').value;
|
const sortBy = document.getElementById('sortBy').value;
|
||||||
|
|
||||||
// 정렬 (관리자 페이지와 동일하게)
|
// 정렬
|
||||||
const sortedFiles = [...this.filteredFiles].sort((a, b) => {
|
const sortedFiles = [...this.filteredFiles].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'title':
|
case 'title':
|
||||||
@@ -74,7 +91,7 @@ class FileManager {
|
|||||||
return a.category.localeCompare(b.category);
|
return a.category.localeCompare(b.category);
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
return new Date(b.created_at || b.createdAt) - new Date(a.created_at || a.createdAt);
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +115,7 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFileRowHTML(file, rowNumber) {
|
createFileRowHTML(file, rowNumber) {
|
||||||
const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
|
const createdDate = new Date(file.created_at).toLocaleDateString('ko-KR');
|
||||||
const hasAttachments = file.files && file.files.length > 0;
|
const hasAttachments = file.files && file.files.length > 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -108,32 +125,31 @@ class FileManager {
|
|||||||
<span class="category-badge category-${file.category}">${file.category}</span>
|
<span class="category-badge category-${file.category}">${file.category}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-title">
|
<td class="col-title">
|
||||||
<div class="board-title" onclick="fileManager.viewFileInfo('${file.id}')">
|
<div class="board-title" onclick="publicViewer.viewFileInfo('${file.id}')">
|
||||||
${this.escapeHtml(file.title)}
|
${this.escapeHtml(file.title)}
|
||||||
${file.description ? `<br><small style="color: #666; font-weight: normal;">${this.escapeHtml(file.description)}</small>` : ''}
|
${file.description ? `<br><small style="color: #666; font-weight: normal;">${this.escapeHtml(file.description)}</small>` : ''}
|
||||||
${file.tags && file.tags.length > 0 ?
|
${file.tags && file.tags.length > 0 ?
|
||||||
`<br><div style="margin-top: 4px;">${file.tags.map(tag => `<span style="display: inline-block; background: #e5e7eb; color: #374151; padding: 2px 6px; border-radius: 10px; font-size: 0.7rem; margin-right: 4px;">#${this.escapeHtml(tag)}</span>`).join('')}</div>` : ''
|
`<br><div style="margin-top: 4px;">${this.parseJsonTags(file.tags).map(tag => `<span style="display: inline-block; background: #e5e7eb; color: #374151; padding: 2px 6px; border-radius: 10px; font-size: 0.7rem; margin-right: 4px;">#${this.escapeHtml(tag)}</span>`).join('')}</div>` : ''
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-attachment">
|
<td class="col-attachment">
|
||||||
${hasAttachments ?
|
${hasAttachments ?
|
||||||
`<div class="attachment-icons">${file.files.map((f, index) =>
|
`<div class="attachment-list">
|
||||||
`<div class="attachment-file-item" onclick="fileManager.downloadSingleFile('${file.id}', ${index})" title="클릭하여 다운로드">
|
${file.files.map((f, index) =>
|
||||||
<span class="attachment-file-icon">${this.getFileIcon(f.name || f.original_name || 'unknown')}</span>
|
`<div class="attachment-item-public" onclick="publicViewer.downloadSingleFile('${file.id}', ${index})" title="클릭하여 다운로드">
|
||||||
<div class="attachment-file-info">
|
<span class="attachment-file-icon">${this.getFileIcon(f.original_name || 'unknown')}</span>
|
||||||
<div class="attachment-file-name">${this.escapeHtml(f.name || f.original_name || '파일')}</div>
|
<span class="attachment-file-name">${this.escapeHtml(f.original_name || '파일')}</span>
|
||||||
<div class="attachment-file-size">${this.formatFileSize(f.size || 0)}</div>
|
|
||||||
</div>
|
|
||||||
</div>`
|
</div>`
|
||||||
).join('')}</div>` :
|
).join('')}
|
||||||
|
</div>` :
|
||||||
`<span class="no-attachment">-</span>`
|
`<span class="no-attachment">-</span>`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-date">${createdDate}</td>
|
<td class="col-date">${createdDate}</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
${hasAttachments ?
|
${hasAttachments ?
|
||||||
`<button class="action-btn btn-download" onclick="fileManager.downloadFiles('${file.id}')" title="다운로드">📥</button>` :
|
`<button class="action-btn btn-download" onclick="publicViewer.downloadFiles('${file.id}')" title="다운로드">📥</button>` :
|
||||||
`<span class="no-attachment">-</span>`
|
`<span class="no-attachment">-</span>`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
@@ -141,6 +157,17 @@ class FileManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parseJsonTags(tags) {
|
||||||
|
try {
|
||||||
|
if (typeof tags === 'string') {
|
||||||
|
return JSON.parse(tags);
|
||||||
|
}
|
||||||
|
return Array.isArray(tags) ? tags : [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getFileIcon(fileName) {
|
getFileIcon(fileName) {
|
||||||
const ext = fileName.split('.').pop().toLowerCase();
|
const ext = fileName.split('.').pop().toLowerCase();
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -173,17 +200,16 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showDetailView(file) {
|
showDetailView(file) {
|
||||||
// 메인 컨테이너 숨기기
|
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
|
|
||||||
// 상세보기 컨테이너 생성
|
|
||||||
const detailContainer = document.createElement('div');
|
const detailContainer = document.createElement('div');
|
||||||
detailContainer.className = 'detail-container';
|
detailContainer.className = 'detail-container';
|
||||||
detailContainer.id = 'detailContainer';
|
detailContainer.id = 'detailContainer';
|
||||||
|
|
||||||
const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
|
const createdDate = new Date(file.created_at).toLocaleDateString('ko-KR');
|
||||||
const updatedDate = new Date(file.updated_at || file.updatedAt).toLocaleDateString('ko-KR');
|
const updatedDate = new Date(file.updated_at).toLocaleDateString('ko-KR');
|
||||||
|
const tags = this.parseJsonTags(file.tags);
|
||||||
|
|
||||||
detailContainer.innerHTML = `
|
detailContainer.innerHTML = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -195,7 +221,7 @@ class FileManager {
|
|||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<h2>📄 ${this.escapeHtml(file.title)}</h2>
|
<h2>📄 ${this.escapeHtml(file.title)}</h2>
|
||||||
<button class="back-btn" onclick="fileManager.hideDetailView()">
|
<button class="back-btn" onclick="publicViewer.hideDetailView()">
|
||||||
← 목록으로 돌아가기
|
← 목록으로 돌아가기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,12 +242,12 @@ class FileManager {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${file.tags && file.tags.length > 0 ? `
|
${tags && tags.length > 0 ? `
|
||||||
<div class="info-group">
|
<div class="info-group">
|
||||||
<label>🏷️ 태그</label>
|
<label>🏷️ 태그</label>
|
||||||
<div class="info-value">
|
<div class="info-value">
|
||||||
<div class="tags-container">
|
<div class="tags-container">
|
||||||
${file.tags.map(tag => `<span class="tag">#${this.escapeHtml(tag)}</span>`).join('')}
|
${tags.map(tag => `<span class="tag">#${this.escapeHtml(tag)}</span>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
@@ -233,16 +259,16 @@ class FileManager {
|
|||||||
<div class="attachments-list">
|
<div class="attachments-list">
|
||||||
${file.files.map((f, index) => `
|
${file.files.map((f, index) => `
|
||||||
<div class="attachment-item">
|
<div class="attachment-item">
|
||||||
<span class="attachment-icon">${this.getFileIcon(f.name || f.original_name || 'unknown')}</span>
|
<span class="attachment-icon">${this.getFileIcon(f.original_name || 'unknown')}</span>
|
||||||
<span class="attachment-name">${this.escapeHtml(f.name || f.original_name || '파일')}</span>
|
<span class="attachment-name">${this.escapeHtml(f.original_name || '파일')}</span>
|
||||||
<button class="download-single-btn" onclick="fileManager.downloadSingleFile('${file.id}', ${index})" title="다운로드">
|
<button class="download-single-btn" onclick="publicViewer.downloadSingleFile('${file.id}', ${index})" title="다운로드">
|
||||||
📥 다운로드
|
📥 다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="attachment-actions">
|
<div class="attachment-actions">
|
||||||
<button class="download-all-btn" onclick="fileManager.downloadFiles('${file.id}')" title="모든 파일 다운로드">
|
<button class="download-all-btn" onclick="publicViewer.downloadFiles('${file.id}')" title="모든 파일 다운로드">
|
||||||
📦 모든 파일 다운로드
|
📦 모든 파일 다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,18 +306,13 @@ class FileManager {
|
|||||||
detailContainer.remove();
|
detailContainer.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메인 컨테이너 다시 보이기
|
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFiles(id) {
|
async downloadFiles(id) {
|
||||||
const file = this.files.find(f => f.id === id);
|
const file = this.files.find(f => f.id === id);
|
||||||
if (!file) {
|
if (!file || !file.files || file.files.length === 0) {
|
||||||
this.showNotification('파일을 찾을 수 없습니다.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!file.files || file.files.length === 0) {
|
|
||||||
this.showNotification('첨부파일이 없습니다.', 'error');
|
this.showNotification('첨부파일이 없습니다.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -299,12 +320,13 @@ class FileManager {
|
|||||||
try {
|
try {
|
||||||
if (file.files.length === 1) {
|
if (file.files.length === 1) {
|
||||||
// 단일 파일: 직접 다운로드
|
// 단일 파일: 직접 다운로드
|
||||||
await this.downloadSingleFileData(file.files[0]);
|
await this.downloadSingleFile(id, 0);
|
||||||
this.showNotification(`파일 다운로드 완료: ${file.files[0].name || file.files[0].original_name}`, 'success');
|
|
||||||
} else {
|
} else {
|
||||||
// 다중 파일: localStorage에서 base64 데이터를 각각 다운로드
|
// 다중 파일: 각각 다운로드
|
||||||
for (const fileData of file.files) {
|
for (let i = 0; i < file.files.length; i++) {
|
||||||
await this.downloadSingleFileData(fileData);
|
await this.downloadSingleFile(id, i);
|
||||||
|
// 짧은 딜레이를 추가하여 브라우저가 다운로드를 처리할 시간을 줌
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
this.showNotification(`${file.files.length}개 파일 다운로드 완료`, 'success');
|
this.showNotification(`${file.files.length}개 파일 다운로드 완료`, 'success');
|
||||||
}
|
}
|
||||||
@@ -314,41 +336,134 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadSingleFile(fileId, fileIndex) {
|
async downloadSingleFile(fileId, attachmentIndex) {
|
||||||
|
try {
|
||||||
|
// 다운로드 시작 로딩 표시
|
||||||
|
this.showLoading(true);
|
||||||
|
|
||||||
|
console.log('downloadSingleFile 호출됨:', fileId, attachmentIndex);
|
||||||
const file = this.files.find(f => f.id === fileId);
|
const file = this.files.find(f => f.id === fileId);
|
||||||
if (!file) {
|
console.log('찾은 파일:', file);
|
||||||
this.showNotification('파일을 찾을 수 없습니다.', 'error');
|
|
||||||
return;
|
if (!file || !file.files[attachmentIndex]) {
|
||||||
}
|
console.log('파일 또는 첨부파일을 찾을 수 없음');
|
||||||
if (!file.files || !file.files[fileIndex]) {
|
throw new Error('파일을 찾을 수 없습니다.');
|
||||||
this.showNotification('첨부파일을 찾을 수 없습니다.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const attachmentId = file.files[attachmentIndex].id;
|
||||||
const fileData = file.files[fileIndex];
|
const downloadUrl = `/api/download/${fileId}/${attachmentId}`;
|
||||||
await this.downloadSingleFileData(fileData);
|
console.log('다운로드 URL:', downloadUrl);
|
||||||
this.showNotification(`파일 다운로드 완료: ${fileData.name || fileData.original_name}`, 'success');
|
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
console.log('응답 상태:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.log('응답 오류:', errorText);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('다운로드 시작...');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// 파일명을 서버에서 전송된 정보에서 추출 (개선된 방식)
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = file.files[attachmentIndex].original_name || `download_${Date.now()}`;
|
||||||
|
|
||||||
|
console.log('📁 다운로드 파일명 처리:', {
|
||||||
|
original_name: file.files[attachmentIndex].original_name,
|
||||||
|
content_disposition: contentDisposition,
|
||||||
|
default_filename: filename
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
// RFC 5987 filename* 파라미터를 우선 처리 (UTF-8 지원)
|
||||||
|
const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
if (filenameStarMatch) {
|
||||||
|
filename = decodeURIComponent(filenameStarMatch[1]);
|
||||||
|
console.log('📁 UTF-8 파일명 추출:', filename);
|
||||||
|
} else {
|
||||||
|
// 일반 filename 파라미터 처리
|
||||||
|
const filenameMatch = contentDisposition.match(/filename="?([^";\r\n]+)"?/);
|
||||||
|
if (filenameMatch) {
|
||||||
|
filename = filenameMatch[1];
|
||||||
|
console.log('📁 기본 파일명 추출:', filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일명이 여전히 비어있다면 기본값 사용
|
||||||
|
if (!filename || filename.trim() === '') {
|
||||||
|
filename = file.files[attachmentIndex].original_name || `download_${Date.now()}`;
|
||||||
|
console.log('📁 기본 파일명 사용:', filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('다운로드 완료');
|
||||||
|
this.showLoading(false);
|
||||||
|
|
||||||
|
if (arguments.length === 2) { // 단일 파일 다운로드인 경우만 알림 표시
|
||||||
|
this.showNotification(`파일 다운로드 완료: ${filename}`, 'success');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('개별 파일 다운로드 오류:', error);
|
console.error('downloadSingleFile 오류:', error);
|
||||||
|
this.showLoading(false);
|
||||||
this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
|
this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadSingleFileData(fileData) {
|
updatePagination() {
|
||||||
if (fileData.data) {
|
const totalPages = Math.max(1, Math.ceil(this.filteredFiles.length / this.itemsPerPage));
|
||||||
// localStorage의 base64 데이터 다운로드
|
const pagination = document.getElementById('pagination');
|
||||||
const link = document.createElement('a');
|
const prevBtn = document.getElementById('prevPage');
|
||||||
link.href = fileData.data;
|
const nextBtn = document.getElementById('nextPage');
|
||||||
link.download = fileData.name || fileData.original_name || 'file';
|
const pageInfo = document.getElementById('pageInfo');
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
pagination.style.display = 'flex';
|
||||||
document.body.removeChild(link);
|
|
||||||
|
prevBtn.disabled = this.currentPage <= 1;
|
||||||
|
nextBtn.disabled = this.currentPage >= totalPages || this.filteredFiles.length === 0;
|
||||||
|
|
||||||
|
const displayTotalPages = this.filteredFiles.length === 0 ? 1 : totalPages;
|
||||||
|
const displayCurrentPage = this.filteredFiles.length === 0 ? 1 : this.currentPage;
|
||||||
|
pageInfo.textContent = `${displayCurrentPage} / ${displayTotalPages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPrevPage() {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.currentPage--;
|
||||||
|
this.renderFiles();
|
||||||
|
this.updatePagination();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToNextPage() {
|
||||||
|
const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
|
||||||
|
if (this.currentPage < totalPages) {
|
||||||
|
this.currentPage++;
|
||||||
|
this.renderFiles();
|
||||||
|
this.updatePagination();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(show) {
|
||||||
|
const loadingEl = document.getElementById('loadingMessage');
|
||||||
|
if (loadingEl) {
|
||||||
|
loadingEl.style.display = show ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotification(message, type = 'info') {
|
showNotification(message, type = 'info') {
|
||||||
// 간단한 알림 표시
|
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `notification ${type}`;
|
notification.className = `notification ${type}`;
|
||||||
notification.textContent = message;
|
notification.textContent = message;
|
||||||
@@ -374,102 +489,15 @@ class FileManager {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePagination() {
|
|
||||||
const totalPages = Math.max(1, Math.ceil(this.filteredFiles.length / this.itemsPerPage));
|
|
||||||
const pagination = document.getElementById('pagination');
|
|
||||||
const prevBtn = document.getElementById('prevPage');
|
|
||||||
const nextBtn = document.getElementById('nextPage');
|
|
||||||
const pageInfo = document.getElementById('pageInfo');
|
|
||||||
|
|
||||||
// 항상 페이지네이션을 표시
|
|
||||||
pagination.style.display = 'flex';
|
|
||||||
|
|
||||||
// 페이지 버튼 상태 업데이트
|
|
||||||
prevBtn.disabled = this.currentPage <= 1;
|
|
||||||
nextBtn.disabled = this.currentPage >= totalPages || this.filteredFiles.length === 0;
|
|
||||||
|
|
||||||
// 페이지 정보 표시 (아이템이 없어도 1/1로 표시)
|
|
||||||
const displayTotalPages = this.filteredFiles.length === 0 ? 1 : totalPages;
|
|
||||||
const displayCurrentPage = this.filteredFiles.length === 0 ? 1 : this.currentPage;
|
|
||||||
pageInfo.textContent = `${displayCurrentPage} / ${displayTotalPages}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
goToPrevPage() {
|
|
||||||
if (this.currentPage > 1) {
|
|
||||||
this.currentPage--;
|
|
||||||
this.renderFiles();
|
|
||||||
this.updatePagination();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToNextPage() {
|
|
||||||
const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
|
|
||||||
if (this.currentPage < totalPages) {
|
|
||||||
this.currentPage++;
|
|
||||||
this.renderFiles();
|
|
||||||
this.updatePagination();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
loadFiles() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('fileManagerData');
|
|
||||||
const files = stored ? JSON.parse(stored) : [];
|
|
||||||
|
|
||||||
// 기존 localStorage 데이터의 호환성을 위해 컴럼명 변환
|
|
||||||
return files.map(file => ({
|
|
||||||
...file,
|
|
||||||
created_at: file.created_at || file.createdAt || new Date().toISOString(),
|
|
||||||
updated_at: file.updated_at || file.updatedAt || new Date().toISOString()
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('파일 로드 중 오류:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveFiles() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('fileManagerData', JSON.stringify(this.files));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('파일 저장 중 오류:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
showMessage(message, type = 'success') {
|
|
||||||
const messageEl = document.createElement('div');
|
|
||||||
messageEl.className = `message ${type}`;
|
|
||||||
messageEl.textContent = message;
|
|
||||||
messageEl.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: ${type === 'error' ? '#ef4444' : '#10b981'};
|
|
||||||
color: white;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(messageEl);
|
|
||||||
setTimeout(() => {
|
|
||||||
messageEl.style.animation = 'slideOut 0.3s ease-out';
|
|
||||||
setTimeout(() => messageEl.remove(), 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전역 인스턴스 생성
|
// 전역 인스턴스 생성
|
||||||
let fileManager;
|
let publicViewer;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
fileManager = new FileManager();
|
publicViewer = new PublicFileViewer();
|
||||||
});
|
});
|
73
scripts/init-database.js
Normal file
73
scripts/init-database.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
|
||||||
|
// 데이터베이스 파일 경로
|
||||||
|
const dbPath = path.join(__dirname, '../database/jaryo.db');
|
||||||
|
const schemaPath = path.join(__dirname, '../database/schema.sql');
|
||||||
|
|
||||||
|
// database 폴더가 없으면 생성
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploads 폴더도 생성
|
||||||
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔧 SQLite 데이터베이스 초기화 시작...');
|
||||||
|
|
||||||
|
// 데이터베이스 연결
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ 데이터베이스 연결 오류:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('✅ SQLite 데이터베이스 연결 성공');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 스키마 파일 읽기 및 실행
|
||||||
|
fs.readFile(schemaPath, 'utf8', (err, schema) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ 스키마 파일 읽기 오류:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여러 SQL 문을 분리하여 실행
|
||||||
|
const statements = schema.split(';').filter(stmt => stmt.trim().length > 0);
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
statements.forEach((statement, index) => {
|
||||||
|
if (statement.trim()) {
|
||||||
|
db.run(statement + ';', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`❌ SQL 실행 오류 (${index + 1}):`, err.message);
|
||||||
|
console.error('실행하려던 SQL:', statement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 데이터베이스 스키마 생성 완료');
|
||||||
|
|
||||||
|
// 데이터 확인
|
||||||
|
db.all('SELECT COUNT(*) as count FROM files', (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ 데이터 확인 오류:', err.message);
|
||||||
|
} else {
|
||||||
|
console.log(`📊 파일 테이블 레코드 수: ${rows[0].count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ 데이터베이스 종료 오류:', err.message);
|
||||||
|
} else {
|
||||||
|
console.log('🏁 데이터베이스 초기화 완료');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
788
server.js
Normal file
788
server.js
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const session = require('express-session');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const DatabaseHelper = require('./database/db-helper');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3005;
|
||||||
|
|
||||||
|
// 데이터베이스 헬퍼 인스턴스
|
||||||
|
const db = new DatabaseHelper();
|
||||||
|
|
||||||
|
// 미들웨어 설정
|
||||||
|
app.use(cors({
|
||||||
|
origin: ['http://localhost:3001', 'http://127.0.0.1:3001'],
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
// 세션 설정
|
||||||
|
app.use(session({
|
||||||
|
secret: 'jaryo-file-manager-secret-key-2024',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
secure: false, // HTTPS에서는 true로 설정
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 24시간
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 모든 요청 로깅 미들웨어
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.url.includes('/api/categories')) {
|
||||||
|
console.log(`📨 ${req.method} ${req.url} - Time: ${new Date().toISOString()}`);
|
||||||
|
console.log('Headers:', req.headers);
|
||||||
|
console.log('Body:', req.body);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정적 파일 서빙
|
||||||
|
app.use(express.static(__dirname));
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
|
|
||||||
|
// 루트 경로에서 메인 페이지로 리다이렉트
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('/index.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multer 설정 (파일 업로드)
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const uploadDir = 'uploads';
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// 한글 파일명 처리를 위해 Buffer로 디코딩
|
||||||
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const extension = path.extname(originalName);
|
||||||
|
const baseName = path.basename(originalName, extension);
|
||||||
|
|
||||||
|
// 안전한 파일명 생성 (특수문자 제거)
|
||||||
|
const safeName = baseName.replace(/[<>:"/\\|?*]/g, '_');
|
||||||
|
cb(null, safeName + '-' + uniqueSuffix + extension);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: 2 * 1024 * 1024 * 1024, // 2GB 제한
|
||||||
|
files: 20, // 최대 파일 개수
|
||||||
|
fieldSize: 100 * 1024 * 1024 // 필드 크기 제한
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// 모든 파일 타입 허용
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 인증 미들웨어
|
||||||
|
const requireAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.userId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: '로그인이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.getUserById(req.session.userId);
|
||||||
|
if (!user) {
|
||||||
|
req.session.destroy();
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: '유효하지 않은 세션입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('인증 미들웨어 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '인증 처리 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관리자 권한 확인 미들웨어
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: '관리자 권한이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 라우트
|
||||||
|
|
||||||
|
// 회원가입
|
||||||
|
app.post('/api/auth/signup', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, name } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password || !name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '이메일, 비밀번호, 이름은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 중복 확인
|
||||||
|
const existingUser = await db.getUserByEmail(email);
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '이미 등록된 이메일입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 해시화
|
||||||
|
const saltRounds = 10;
|
||||||
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
|
// 사용자 생성
|
||||||
|
const result = await db.createUser({
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
name,
|
||||||
|
role: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '회원가입이 완료되었습니다.',
|
||||||
|
data: { id: result.id }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('회원가입 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '회원가입 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '이메일과 비밀번호를 입력해주세요.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 확인
|
||||||
|
const user = await db.getUserByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 확인
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세션 설정
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.userEmail = user.email;
|
||||||
|
req.session.userName = user.name;
|
||||||
|
req.session.userRole = user.role;
|
||||||
|
|
||||||
|
// 마지막 로그인 시간 업데이트
|
||||||
|
await db.updateUserLastLogin(user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '로그인 성공',
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('로그인 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '로그인 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('로그아웃 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '로그아웃 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '로그아웃되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 사용자 정보 조회
|
||||||
|
app.get('/api/auth/me', requireAuth, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: req.user.id,
|
||||||
|
email: req.user.email,
|
||||||
|
name: req.user.name,
|
||||||
|
role: req.user.role,
|
||||||
|
last_login: req.user.last_login
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 세션 상태 확인
|
||||||
|
app.get('/api/auth/session', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.userId) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
user: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.getUserById(req.session.userId);
|
||||||
|
if (!user) {
|
||||||
|
req.session.destroy();
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
user: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
last_login: user.last_login
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 확인 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '세션 확인 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 목록 조회 (관리자용)
|
||||||
|
app.get('/api/files', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { search, category, limit = 100, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
let files;
|
||||||
|
if (search) {
|
||||||
|
files = await db.searchFiles(search, category, parseInt(limit));
|
||||||
|
} else {
|
||||||
|
files = await db.getAllFiles(parseInt(limit), parseInt(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: files,
|
||||||
|
count: files.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 공개 파일 목록 조회 (일반 사용자용)
|
||||||
|
app.get('/api/files/public', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { search, category, limit = 100, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
let files;
|
||||||
|
if (search) {
|
||||||
|
files = await db.searchFiles(search, category, parseInt(limit));
|
||||||
|
} else {
|
||||||
|
files = await db.getAllFiles(parseInt(limit), parseInt(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: files,
|
||||||
|
count: files.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공개 파일 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 추가
|
||||||
|
app.post('/api/files', requireAuth, upload.array('files'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, description, category, tags } = req.body;
|
||||||
|
|
||||||
|
if (!title || !category) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '제목과 카테고리는 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = db.generateId();
|
||||||
|
const fileData = {
|
||||||
|
id: fileId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
tags: tags ? (typeof tags === 'string' ? JSON.parse(tags) : tags) : [],
|
||||||
|
user_id: req.user.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 정보 저장
|
||||||
|
const result = await db.addFile(fileData);
|
||||||
|
|
||||||
|
// 첨부파일 처리
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
for (const file of req.files) {
|
||||||
|
// 한글 파일명 처리
|
||||||
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
|
||||||
|
await db.addFileAttachment(fileId, {
|
||||||
|
original_name: originalName,
|
||||||
|
file_name: file.filename,
|
||||||
|
file_path: file.path,
|
||||||
|
file_size: file.size,
|
||||||
|
mime_type: file.mimetype
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { id: fileId, ...result }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 추가 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 수정
|
||||||
|
app.put('/api/files/:id', requireAuth, upload.array('files'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { title, description, category, tags, filesToDelete } = req.body;
|
||||||
|
|
||||||
|
console.log('🔄 파일 업데이트 시작:', id);
|
||||||
|
console.log('📋 업데이트 데이터:', { title, description, category, tags });
|
||||||
|
console.log('🗑️ 삭제할 첨부파일:', filesToDelete);
|
||||||
|
console.log('📎 새 첨부파일 개수:', req.files ? req.files.length : 0);
|
||||||
|
|
||||||
|
// 기본 파일 정보 업데이트
|
||||||
|
const updates = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
tags: tags ? (typeof tags === 'string' ? tags : JSON.stringify(tags)) : '[]'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db.updateFile(id, updates);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '파일을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 삭제 처리
|
||||||
|
if (filesToDelete) {
|
||||||
|
const deleteIds = typeof filesToDelete === 'string' ? JSON.parse(filesToDelete) : filesToDelete;
|
||||||
|
console.log('삭제 처리할 첨부파일 ID들:', deleteIds);
|
||||||
|
|
||||||
|
if (Array.isArray(deleteIds) && deleteIds.length > 0) {
|
||||||
|
for (const attachmentId of deleteIds) {
|
||||||
|
try {
|
||||||
|
// 첨부파일 정보 조회
|
||||||
|
const attachments = await db.getFileAttachments(id);
|
||||||
|
const attachment = attachments.find(a => a.id == attachmentId);
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
// 실제 파일 삭제
|
||||||
|
const filePath = path.join(__dirname, attachment.file_path);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.log('실제 파일 삭제됨:', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스에서 첨부파일 삭제
|
||||||
|
await db.deleteFileAttachment(attachmentId);
|
||||||
|
console.log('DB에서 첨부파일 삭제됨:', attachmentId);
|
||||||
|
}
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.error('첨부파일 삭제 오류:', deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 첨부파일 추가
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
console.log('새 첨부파일 추가 시작');
|
||||||
|
for (const file of req.files) {
|
||||||
|
try {
|
||||||
|
// 한글 파일명 처리
|
||||||
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
|
||||||
|
await db.addFileAttachment(id, {
|
||||||
|
original_name: originalName,
|
||||||
|
file_name: file.filename,
|
||||||
|
file_path: file.path,
|
||||||
|
file_size: file.size,
|
||||||
|
mime_type: file.mimetype
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('새 첨부파일 추가됨:', originalName);
|
||||||
|
} catch (addError) {
|
||||||
|
console.error('새 첨부파일 추가 오류:', addError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ 파일 업데이트 완료');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 수정 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 삭제
|
||||||
|
app.delete('/api/files/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await db.deleteFile(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '파일을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 목록 조회
|
||||||
|
app.get('/api/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await db.getCategories();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: categories
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테스트용 엔드포인트
|
||||||
|
app.get('/api/categories/test', (req, res) => {
|
||||||
|
console.log('📋 테스트 엔드포인트 호출됨');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '테스트 성공',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 추가
|
||||||
|
app.post('/api/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '카테고리 이름은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.addCategory(name);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 추가 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 수정
|
||||||
|
app.put('/api/categories/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('🔄 카테고리 수정 요청 받음');
|
||||||
|
console.log('URL:', req.url);
|
||||||
|
console.log('Method:', req.method);
|
||||||
|
console.log('Params:', req.params);
|
||||||
|
console.log('Body:', req.body);
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
console.log('추출된 ID:', id, 'Type:', typeof id);
|
||||||
|
console.log('추출된 name:', name);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
console.log('❌ 카테고리 이름이 없음');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '카테고리 이름은 필수입니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 데이터베이스 업데이트 시작');
|
||||||
|
const result = await db.updateCategory(id, name);
|
||||||
|
console.log('📝 데이터베이스 업데이트 결과:', result);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
console.log('❌ 변경된 행이 없음 - 카테고리를 찾을 수 없음');
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '카테고리를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ 카테고리 수정 성공');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 수정 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 삭제
|
||||||
|
app.delete('/api/categories/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await db.deleteCategory(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '카테고리를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 삭제 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 통계 정보 조회
|
||||||
|
app.get('/api/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await db.getStats();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('통계 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
app.get('/api/download/:id/:attachmentId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, attachmentId } = req.params;
|
||||||
|
|
||||||
|
// 첨부파일 정보 조회 (간단한 쿼리로 대체)
|
||||||
|
await db.connect();
|
||||||
|
const query = 'SELECT * FROM file_attachments WHERE id = ? AND file_id = ?';
|
||||||
|
|
||||||
|
db.db.get(query, [attachmentId, id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('파일 조회 오류:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '파일을 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, row.file_path);
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
// 한글 파일명을 위한 개선된 헤더 설정
|
||||||
|
console.log('📁 다운로드 파일 정보:', {
|
||||||
|
original_name: row.original_name,
|
||||||
|
file_path: row.file_path,
|
||||||
|
storage_path: filePath
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalName = row.original_name || 'download';
|
||||||
|
const encodedName = encodeURIComponent(originalName);
|
||||||
|
|
||||||
|
// RFC 5987을 준수하는 헤더 설정 (한글 파일명 지원)
|
||||||
|
res.setHeader('Content-Disposition',
|
||||||
|
`attachment; filename*=UTF-8''${encodedName}`);
|
||||||
|
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Length', row.file_size || fs.statSync(filePath).size);
|
||||||
|
|
||||||
|
// 원본 파일명으로 다운로드
|
||||||
|
res.download(filePath, originalName, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('📁 다운로드 오류:', err);
|
||||||
|
} else {
|
||||||
|
console.log('📁 다운로드 완료:', originalName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '파일이 존재하지 않습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 핸들러
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
console.error('서버 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '서버 내부 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 핸들러
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: '요청한 리소스를 찾을 수 없습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 서버 시작
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 자료실 서버가 포트 ${PORT}에서 실행중입니다.`);
|
||||||
|
console.log(`📱 Admin 페이지: http://localhost:${PORT}/admin/index.html`);
|
||||||
|
console.log(`🌐 Main 페이지: http://localhost:${PORT}/index.html`);
|
||||||
|
console.log(`📊 API: http://localhost:${PORT}/api/files`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로세스 종료 시 데이터베이스 연결 종료
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('\n📝 서버를 종료합니다...');
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('\n📝 서버를 종료합니다...');
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
68
start-service.sh
Normal file
68
start-service.sh
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 시놀로지 NAS용 서비스 시작 스크립트
|
||||||
|
# 사용법: ./start-service.sh
|
||||||
|
|
||||||
|
# 프로젝트 디렉토리 설정 (실제 경로에 맞게 수정)
|
||||||
|
PROJECT_DIR="/volume1/web/jaryo"
|
||||||
|
LOG_FILE="/volume1/web/jaryo/logs/app.log"
|
||||||
|
PID_FILE="/volume1/web/jaryo/app.pid"
|
||||||
|
|
||||||
|
# 로그 디렉토리 생성
|
||||||
|
mkdir -p "$(dirname $LOG_FILE)"
|
||||||
|
|
||||||
|
# Node.js 경로 확인 (시놀로지 기본 설치 경로)
|
||||||
|
NODE_PATH="/volume1/@appstore/Node.js_v18/usr/local/bin/node"
|
||||||
|
if [ ! -f "$NODE_PATH" ]; then
|
||||||
|
NODE_PATH="node"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NPM 경로 확인
|
||||||
|
NPM_PATH="/volume1/@appstore/Node.js_v18/usr/local/bin/npm"
|
||||||
|
if [ ! -f "$NPM_PATH" ]; then
|
||||||
|
NPM_PATH="npm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Jaryo File Manager 서비스 시작 ==="
|
||||||
|
echo "프로젝트 디렉토리: $PROJECT_DIR"
|
||||||
|
echo "Node.js 경로: $NODE_PATH"
|
||||||
|
echo "로그 파일: $LOG_FILE"
|
||||||
|
|
||||||
|
# 프로젝트 디렉토리로 이동
|
||||||
|
cd "$PROJECT_DIR" || {
|
||||||
|
echo "오류: 프로젝트 디렉토리를 찾을 수 없습니다: $PROJECT_DIR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 의존성 설치 확인
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo "의존성 설치 중..."
|
||||||
|
$NPM_PATH install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 데이터베이스 초기화
|
||||||
|
echo "데이터베이스 초기화 중..."
|
||||||
|
$NODE_PATH scripts/init-database.js
|
||||||
|
|
||||||
|
# 기존 프로세스 종료
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
OLD_PID=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||||
|
echo "기존 프로세스 종료 중 (PID: $OLD_PID)..."
|
||||||
|
kill "$OLD_PID"
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 서비스 시작
|
||||||
|
echo "서비스 시작 중..."
|
||||||
|
nohup $NODE_PATH server.js > "$LOG_FILE" 2>&1 &
|
||||||
|
NEW_PID=$!
|
||||||
|
|
||||||
|
# PID 저장
|
||||||
|
echo $NEW_PID > "$PID_FILE"
|
||||||
|
|
||||||
|
echo "서비스가 시작되었습니다. PID: $NEW_PID"
|
||||||
|
echo "로그 확인: tail -f $LOG_FILE"
|
||||||
|
echo "서비스 중지: kill $NEW_PID"
|
36
stop-service.sh
Normal file
36
stop-service.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 시놀로지 NAS용 서비스 중지 스크립트
|
||||||
|
# 사용법: ./stop-service.sh
|
||||||
|
|
||||||
|
PROJECT_DIR="/volume1/web/jaryo"
|
||||||
|
PID_FILE="/volume1/web/jaryo/app.pid"
|
||||||
|
|
||||||
|
echo "=== Jaryo File Manager 서비스 중지 ==="
|
||||||
|
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
echo "프로세스 ID: $PID"
|
||||||
|
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "서비스 중지 중..."
|
||||||
|
kill "$PID"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 강제 종료 확인
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "강제 종료 중..."
|
||||||
|
kill -9 "$PID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "서비스가 중지되었습니다."
|
||||||
|
else
|
||||||
|
echo "프로세스가 이미 종료되었습니다."
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
else
|
||||||
|
echo "PID 파일을 찾을 수 없습니다. 수동으로 프로세스를 확인하세요."
|
||||||
|
echo "실행 중인 Node.js 프로세스:"
|
||||||
|
ps aux | grep "node server.js" | grep -v grep
|
||||||
|
fi
|
231
styles.css
231
styles.css
@@ -39,6 +39,27 @@ header p {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* 페이지네이션 스타일 */
|
/* 페이지네이션 스타일 */
|
||||||
|
|
||||||
.auth-section {
|
.auth-section {
|
||||||
@@ -46,6 +67,83 @@ header p {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
color: #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.offline {
|
||||||
|
color: #ed8936;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-buttons {
|
.auth-buttons {
|
||||||
@@ -243,6 +341,97 @@ header p {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: #48bb78 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
background: #38a169 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 파일 추가 섹션 스타일 */
|
||||||
|
.add-section {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header h2 {
|
||||||
|
color: #4a5568;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #e53e3e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #48bb78;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background: #38a169;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -466,6 +655,34 @@ header p {
|
|||||||
color: #10b981;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 일반 사용자 페이지 첨부파일 목록 - 관리자와 동일한 스타일 */
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기존 스타일 유지 (하위 호환성) */
|
||||||
.attachment-icons {
|
.attachment-icons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -495,11 +712,10 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.attachment-file-icon {
|
.attachment-file-icon {
|
||||||
font-size: 1.1rem;
|
font-size: 0.9rem;
|
||||||
width: auto;
|
min-width: 16px;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-file-info {
|
.attachment-file-info {
|
||||||
@@ -512,12 +728,13 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.attachment-file-name {
|
.attachment-file-name {
|
||||||
font-weight: 500;
|
flex: 1;
|
||||||
color: #374151;
|
font-size: 0.75rem;
|
||||||
|
color: #475569;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: 0.8rem;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-file-size {
|
.attachment-file-size {
|
||||||
|
248
synology-setup.md
Normal file
248
synology-setup.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# 시놀로지 NAS에서 Jaryo File Manager 서비스 실행 가이드
|
||||||
|
|
||||||
|
## 1. 사전 준비사항
|
||||||
|
|
||||||
|
### 1.1 DSM 패키지 설치
|
||||||
|
1. **DSM 제어판** → **패키지 센터** 접속
|
||||||
|
2. 다음 패키지들을 설치:
|
||||||
|
- **Node.js** (최신 LTS 버전 권장)
|
||||||
|
- **Git Server** (선택사항, 소스코드 관리용)
|
||||||
|
- **Web Station** (선택사항, 웹 서버 프록시용)
|
||||||
|
|
||||||
|
### 1.2 SSH 활성화
|
||||||
|
1. **DSM 제어판** → **터미널 및 SNMP** → **SSH 서비스 활성화**
|
||||||
|
2. 포트 번호 확인 (기본: 22)
|
||||||
|
|
||||||
|
## 2. 프로젝트 배포
|
||||||
|
|
||||||
|
### 2.1 방법 1: 직접 파일 업로드 (간단한 방법)
|
||||||
|
|
||||||
|
1. **File Station**에서 `/volume1/web/` 폴더 생성
|
||||||
|
2. 프로젝트 파일들을 `jaryo` 폴더에 업로드
|
||||||
|
3. SSH로 접속하여 설정
|
||||||
|
|
||||||
|
### 2.2 방법 2: Git을 통한 배포 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NAS에 SSH 접속
|
||||||
|
ssh admin@your-nas-ip
|
||||||
|
|
||||||
|
# 프로젝트 디렉토리 생성
|
||||||
|
mkdir -p /volume1/web/jaryo
|
||||||
|
cd /volume1/web/jaryo
|
||||||
|
|
||||||
|
# Git 저장소 클론 (로컬에서 push한 경우)
|
||||||
|
git clone [your-repository-url] .
|
||||||
|
|
||||||
|
# 또는 로컬에서 직접 파일 복사
|
||||||
|
# scp -r ./jaryo/* admin@your-nas-ip:/volume1/web/jaryo/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 서비스 설정 및 실행
|
||||||
|
|
||||||
|
### 3.1 스크립트 권한 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH로 NAS 접속
|
||||||
|
ssh admin@your-nas-ip
|
||||||
|
|
||||||
|
# 프로젝트 디렉토리로 이동
|
||||||
|
cd /volume1/web/jaryo
|
||||||
|
|
||||||
|
# 스크립트 실행 권한 부여
|
||||||
|
chmod +x start-service.sh
|
||||||
|
chmod +x stop-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 서비스 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서비스 시작
|
||||||
|
./start-service.sh
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
tail -f logs/app.log
|
||||||
|
|
||||||
|
# 프로세스 상태 확인
|
||||||
|
ps aux | grep "node server.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 서비스 중지
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서비스 중지
|
||||||
|
./stop-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 자동 시작 설정 (선택사항)
|
||||||
|
|
||||||
|
### 4.1 Task Scheduler 사용
|
||||||
|
|
||||||
|
1. **DSM 제어판** → **작업 스케줄러**
|
||||||
|
2. **작업 생성** → **사용자 정의 스크립트**
|
||||||
|
3. 설정:
|
||||||
|
- **작업 이름**: Jaryo Service Start
|
||||||
|
- **사용자**: root
|
||||||
|
- **스케줄**: 시스템 부팅 시
|
||||||
|
- **작업 설정**: `/volume1/web/jaryo/start-service.sh`
|
||||||
|
|
||||||
|
### 4.2 rc.local 사용 (고급 사용자)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/rc.local 파일 편집
|
||||||
|
sudo vi /etc/rc.local
|
||||||
|
|
||||||
|
# 다음 라인 추가
|
||||||
|
/volume1/web/jaryo/start-service.sh &
|
||||||
|
|
||||||
|
# 파일 저장 후 권한 설정
|
||||||
|
chmod +x /etc/rc.local
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 방화벽 및 포트 설정
|
||||||
|
|
||||||
|
### 5.1 DSM 방화벽 설정
|
||||||
|
|
||||||
|
1. **DSM 제어판** → **보안** → **방화벽**
|
||||||
|
2. **방화벽 규칙 편집** → **규칙 생성**
|
||||||
|
3. 설정:
|
||||||
|
- **포트**: 3005 (애플리케이션 포트)
|
||||||
|
- **프로토콜**: TCP
|
||||||
|
- **소스**: 허용할 IP 범위
|
||||||
|
|
||||||
|
### 5.2 라우터 포트 포워딩 (외부 접속용)
|
||||||
|
|
||||||
|
라우터에서 포트 3005를 NAS의 IP로 포워딩 설정
|
||||||
|
|
||||||
|
## 6. 웹 서버 프록시 설정 (Web Station 사용)
|
||||||
|
|
||||||
|
### 6.1 Web Station 설정
|
||||||
|
|
||||||
|
1. **Web Station** → **가상 호스트** → **생성**
|
||||||
|
2. 설정:
|
||||||
|
- **도메인 이름**: your-domain.com (또는 IP)
|
||||||
|
- **포트**: 80 (또는 443 for HTTPS)
|
||||||
|
- **문서 루트**: `/volume1/web/jaryo`
|
||||||
|
- **HTTP 백엔드 서버**: `http://localhost:3005`
|
||||||
|
|
||||||
|
### 6.2 Apache 설정 (고급)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
# /volume1/web/apache/conf/vhost/VirtualHost.conf
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName your-domain.com
|
||||||
|
DocumentRoot /volume1/web/jaryo
|
||||||
|
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass / http://localhost:3005/
|
||||||
|
ProxyPassReverse / http://localhost:3005/
|
||||||
|
|
||||||
|
<Directory /volume1/web/jaryo>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 모니터링 및 유지보수
|
||||||
|
|
||||||
|
### 7.1 로그 모니터링
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 실시간 로그 확인
|
||||||
|
tail -f /volume1/web/jaryo/logs/app.log
|
||||||
|
|
||||||
|
# 로그 파일 크기 확인
|
||||||
|
du -h /volume1/web/jaryo/logs/app.log
|
||||||
|
|
||||||
|
# 로그 로테이션 설정 (logrotate 사용)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 서비스 상태 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프로세스 확인
|
||||||
|
ps aux | grep "node server.js"
|
||||||
|
|
||||||
|
# 포트 사용 확인
|
||||||
|
netstat -tlnp | grep :3005
|
||||||
|
|
||||||
|
# 메모리 사용량 확인
|
||||||
|
top -p $(cat /volume1/web/jaryo/app.pid)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 백업 설정
|
||||||
|
|
||||||
|
1. **Hyper Backup** 패키지 설치
|
||||||
|
2. `/volume1/web/jaryo` 폴더 백업 스케줄 설정
|
||||||
|
3. 데이터베이스 파일 (`jaryo.db`) 별도 백업 권장
|
||||||
|
|
||||||
|
## 8. 문제 해결
|
||||||
|
|
||||||
|
### 8.1 일반적인 문제들
|
||||||
|
|
||||||
|
**서비스가 시작되지 않는 경우:**
|
||||||
|
```bash
|
||||||
|
# Node.js 설치 확인
|
||||||
|
which node
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# 의존성 재설치
|
||||||
|
cd /volume1/web/jaryo
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 권한 문제 확인
|
||||||
|
ls -la /volume1/web/jaryo/
|
||||||
|
chown -R admin:users /volume1/web/jaryo/
|
||||||
|
```
|
||||||
|
|
||||||
|
**포트 충돌 문제:**
|
||||||
|
```bash
|
||||||
|
# 포트 사용 확인
|
||||||
|
netstat -tlnp | grep :3005
|
||||||
|
|
||||||
|
# 다른 포트로 변경 (server.js 수정)
|
||||||
|
# const PORT = process.env.PORT || 3006;
|
||||||
|
```
|
||||||
|
|
||||||
|
**메모리 부족 문제:**
|
||||||
|
```bash
|
||||||
|
# 메모리 사용량 확인
|
||||||
|
free -h
|
||||||
|
|
||||||
|
# Node.js 메모리 제한 설정
|
||||||
|
# node --max-old-space-size=512 server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 로그 분석
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 에러 로그만 확인
|
||||||
|
grep -i error /volume1/web/jaryo/logs/app.log
|
||||||
|
|
||||||
|
# 최근 100줄 확인
|
||||||
|
tail -100 /volume1/web/jaryo/logs/app.log
|
||||||
|
|
||||||
|
# 특정 시간대 로그 확인
|
||||||
|
grep "2024-01-15" /volume1/web/jaryo/logs/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 보안 고려사항
|
||||||
|
|
||||||
|
1. **HTTPS 설정**: Let's Encrypt 인증서 사용
|
||||||
|
2. **방화벽 강화**: 필요한 포트만 개방
|
||||||
|
3. **정기 업데이트**: Node.js 및 패키지 업데이트
|
||||||
|
4. **백업**: 정기적인 데이터 백업
|
||||||
|
5. **모니터링**: 로그 모니터링 및 알림 설정
|
||||||
|
|
||||||
|
## 10. 성능 최적화
|
||||||
|
|
||||||
|
1. **PM2 사용**: 프로세스 관리자로 PM2 사용 고려
|
||||||
|
2. **캐싱**: 정적 파일 캐싱 설정
|
||||||
|
3. **압축**: gzip 압축 활성화
|
||||||
|
4. **CDN**: 정적 파일 CDN 사용 고려
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**참고**: 이 가이드는 시놀로지 DSM 7.x 기준으로 작성되었습니다. 버전에 따라 일부 설정이 다를 수 있습니다.
|
2
uploads/.gitkeep
Normal file
2
uploads/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# This file ensures the uploads directory is tracked by Git
|
||||||
|
# Uploaded files will be ignored by .gitignore, but the directory structure is preserved
|
Reference in New Issue
Block a user