Add complete Jaryo File Manager with Synology NAS deployment support

This commit is contained in:
2025-08-21 11:22:54 +09:00
parent 122d0e2582
commit a8a31b696a
39 changed files with 9026 additions and 1678 deletions

View File

@@ -10,9 +10,39 @@
"WebFetch(domain:developers.cloudflare.com)",
"Bash(git add:*)",
"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": [],
"ask": []
}
"ask": [],
"additionalDirectories": [
"C:\\c\\Users\\COMTREE\\claude_code"
]
},
"default-mode": "plan"
}

151
.gitignore vendored Normal file
View 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

Binary file not shown.

BIN
.playwright-mcp/-.zip Normal file

Binary file not shown.

BIN
.playwright-mcp/-6-.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

260
admin/api-client.js Normal file
View 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
};

View File

@@ -3,45 +3,72 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>자료실 - CRUD 시스템</title>
<title>자료실 - 관리자</title>
<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>
</head>
<body>
<div class="container">
<header>
<h1>📚 자료실 관리 시스템</h1>
<p>파일과 문서를 효율적으로 관리하세요</p>
<div id="authSection" class="auth-section">
<div id="authButtons" class="auth-buttons" style="display: none;">
<button id="loginBtn" class="auth-btn">🔑 로그인</button>
<button id="signupBtn" class="auth-btn">👤 회원가입</button>
<h1>📚 자료실 관리</h1>
<p>관리자 전용 페이지입니다</p>
<!-- 로그인 폼 -->
<div id="loginSection" class="login-section">
<div class="login-form">
<h3>🔐 관리자 로그인</h3>
<div class="form-group">
<input type="email" id="adminEmail" placeholder="이메일" value="admin@jaryo.com" required>
</div>
<div class="form-group">
<input type="password" id="adminPassword" placeholder="비밀번호" required>
</div>
<button id="loginBtn" class="login-btn">로그인</button>
</div>
<div id="userInfo" class="user-info" style="display: flex;">
<span id="userEmail">오프라인 사용자</span>
<span id="syncStatus" class="sync-status offline">🟡 오프라인</span>
<button id="logoutBtn" class="auth-btn" style="display: none;">🚪 로그아웃</button>
<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>
</header>
<div class="search-section">
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
<select id="categoryFilter">
<option value="">전체 카테고리</option>
<option value="문서">문서</option>
<option value="이미지">이미지</option>
<option value="동영상">동영상</option>
<option value="프레젠테이션">프레젠테이션</option>
<option value="기타">기타</option>
</select>
<button id="searchBtn">🔍 검색</button>
</div>
<!-- 관리자 전용 영역 (로그인 후에만 표시) -->
<div id="adminPanel" style="display: none;">
<div class="search-section">
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
<select id="categoryFilter">
<option value="">전체 카테고리</option>
<option value="문서">문서</option>
<option value="이미지">이미지</option>
<option value="동영상">동영상</option>
<option value="프레젠테이션">프레젠테이션</option>
<option value="기타">기타</option>
</select>
<button id="searchBtn">🔍 검색</button>
</div>
<div class="form-section">
<h2>📁 새 자료 추가</h2>
<form id="fileForm">
<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>
<form id="fileForm">
<div class="form-group">
<label for="fileTitle">제목 *</label>
<input type="text" id="fileTitle" required>
@@ -67,7 +94,7 @@
<div class="form-group">
<label for="fileUpload">파일 첨부 (여러 파일 선택 가능)</label>
<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-icon">📁</div>
<p><strong>파일을 여기로 드래그하거나 클릭하여 선택하세요</strong></p>
@@ -88,6 +115,29 @@
<button type="button" id="cancelBtn">❌ 취소</button>
</div>
</form>
</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">
@@ -128,40 +178,7 @@
</div>
</div>
</div>
</div>
<!-- 인증 모달 -->
<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>
<!-- adminPanel 끝 -->
</div>
</div>
@@ -196,6 +213,50 @@
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
</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">
<button type="submit">💾 저장</button>
<button type="button" id="closeModal">❌ 취소</button>
@@ -204,7 +265,49 @@
</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>
// 페이지 로드 완료 후 디버깅 정보 출력
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>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

50
api-client.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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"

View File

@@ -3,15 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>자료실 - CRUD 시스템</title>
<title>자료실 - 파일 보기</title>
<link rel="stylesheet" href="styles.css">
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
</head>
<body>
<div class="container">
<header>
<h1>📚 자료실 관리 시스템</h1>
<p>파일과 문서를 효율적으로 관리하세요</p>
<h1>📚 자료실</h1>
<p>등록된 자료를 검색하고 다운로드할 수 있습니다</p>
<div class="admin-link">
<a href="/admin/" class="admin-btn">🔑 관리자 페이지</a>
</div>
</header>
<div class="search-section">
@@ -27,7 +29,6 @@
<button id="searchBtn">🔍 검색</button>
</div>
<div class="list-section">
<div class="list-header">
<h2>📋 자료 목록</h2>
@@ -66,10 +67,13 @@
<button id="nextPage" class="page-btn" disabled>다음 ▶</button>
</div>
</div>
<div id="loadingMessage" class="loading" style="display: none;">
<p>데이터를 불러오는 중...</p>
</div>
</div>
<script src="supabase-config.js"></script>
<script src="api-client.js"></script>
<script src="script.js"></script>
</body>
</html>

3105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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
View 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"

354
script.js
View File

@@ -1,8 +1,6 @@
class FileManager {
class PublicFileViewer {
constructor() {
this.files = [];
this.currentEditId = null;
this.isOnline = navigator.onLine;
this.currentPage = 1;
this.itemsPerPage = 10;
this.filteredFiles = [];
@@ -11,16 +9,23 @@ class FileManager {
}
async init() {
// 오프라인 모드로만 실행
this.files = this.loadFiles();
this.filteredFiles = [...this.files];
this.bindEvents();
this.renderFiles();
this.updatePagination();
try {
this.showLoading(true);
await this.loadFiles();
this.filteredFiles = [...this.files];
this.bindEvents();
this.renderFiles();
this.updatePagination();
} catch (error) {
console.error('초기화 오류:', error);
this.showNotification('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
} finally {
this.showLoading(false);
}
}
bindEvents() {
// 검색 및 정렬 이벤트만 유지
// 검색 및 정렬 이벤트
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
document.getElementById('searchInput').addEventListener('keyup', (e) => {
if (e.key === 'Enter') this.handleSearch();
@@ -33,8 +38,20 @@ class FileManager {
document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage());
}
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
async loadFiles() {
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() {
@@ -65,7 +82,7 @@ class FileManager {
const fileList = document.getElementById('fileList');
const sortBy = document.getElementById('sortBy').value;
// 정렬 (관리자 페이지와 동일하게)
// 정렬
const sortedFiles = [...this.filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'title':
@@ -74,7 +91,7 @@ class FileManager {
return a.category.localeCompare(b.category);
case 'date':
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) {
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;
return `
@@ -108,32 +125,31 @@ class FileManager {
<span class="category-badge category-${file.category}">${file.category}</span>
</td>
<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)}
${file.description ? `<br><small style="color: #666; font-weight: normal;">${this.escapeHtml(file.description)}</small>` : ''}
${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>
</td>
<td class="col-attachment">
${hasAttachments ?
`<div class="attachment-icons">${file.files.map((f, index) =>
`<div class="attachment-file-item" onclick="fileManager.downloadSingleFile('${file.id}', ${index})" title="클릭하여 다운로드">
<span class="attachment-file-icon">${this.getFileIcon(f.name || f.original_name || 'unknown')}</span>
<div class="attachment-file-info">
<div class="attachment-file-name">${this.escapeHtml(f.name || f.original_name || '파일')}</div>
<div class="attachment-file-size">${this.formatFileSize(f.size || 0)}</div>
</div>
</div>`
).join('')}</div>` :
`<div class="attachment-list">
${file.files.map((f, index) =>
`<div class="attachment-item-public" onclick="publicViewer.downloadSingleFile('${file.id}', ${index})" title="클릭하여 다운로드">
<span class="attachment-file-icon">${this.getFileIcon(f.original_name || 'unknown')}</span>
<span class="attachment-file-name">${this.escapeHtml(f.original_name || '파일')}</span>
</div>`
).join('')}
</div>` :
`<span class="no-attachment">-</span>`
}
</td>
<td class="col-date">${createdDate}</td>
<td class="col-actions">
${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>`
}
</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) {
const ext = fileName.split('.').pop().toLowerCase();
const iconMap = {
@@ -173,17 +200,16 @@ class FileManager {
}
showDetailView(file) {
// 메인 컨테이너 숨기기
const container = document.querySelector('.container');
container.style.display = 'none';
// 상세보기 컨테이너 생성
const detailContainer = document.createElement('div');
detailContainer.className = 'detail-container';
detailContainer.id = 'detailContainer';
const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
const updatedDate = new Date(file.updated_at || file.updatedAt).toLocaleDateString('ko-KR');
const createdDate = new Date(file.created_at).toLocaleDateString('ko-KR');
const updatedDate = new Date(file.updated_at).toLocaleDateString('ko-KR');
const tags = this.parseJsonTags(file.tags);
detailContainer.innerHTML = `
<div class="container">
@@ -195,7 +221,7 @@ class FileManager {
<div class="detail-section">
<div class="detail-header">
<h2>📄 ${this.escapeHtml(file.title)}</h2>
<button class="back-btn" onclick="fileManager.hideDetailView()">
<button class="back-btn" onclick="publicViewer.hideDetailView()">
← 목록으로 돌아가기
</button>
</div>
@@ -216,12 +242,12 @@ class FileManager {
</div>
</div>
${file.tags && file.tags.length > 0 ? `
${tags && tags.length > 0 ? `
<div class="info-group">
<label>🏷️ 태그</label>
<div class="info-value">
<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>` : ''}
@@ -233,16 +259,16 @@ class FileManager {
<div class="attachments-list">
${file.files.map((f, index) => `
<div class="attachment-item">
<span class="attachment-icon">${this.getFileIcon(f.name || f.original_name || 'unknown')}</span>
<span class="attachment-name">${this.escapeHtml(f.name || f.original_name || '파일')}</span>
<button class="download-single-btn" onclick="fileManager.downloadSingleFile('${file.id}', ${index})" title="다운로드">
<span class="attachment-icon">${this.getFileIcon(f.original_name || 'unknown')}</span>
<span class="attachment-name">${this.escapeHtml(f.original_name || '파일')}</span>
<button class="download-single-btn" onclick="publicViewer.downloadSingleFile('${file.id}', ${index})" title="다운로드">
📥 다운로드
</button>
</div>
`).join('')}
</div>
<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>
</div>
@@ -280,18 +306,13 @@ class FileManager {
detailContainer.remove();
}
// 메인 컨테이너 다시 보이기
const container = document.querySelector('.container');
container.style.display = 'block';
}
async downloadFiles(id) {
const file = this.files.find(f => f.id === id);
if (!file) {
this.showNotification('파일을 찾을 수 없습니다.', 'error');
return;
}
if (!file.files || file.files.length === 0) {
if (!file || !file.files || file.files.length === 0) {
this.showNotification('첨부파일이 없습니다.', 'error');
return;
}
@@ -299,12 +320,13 @@ class FileManager {
try {
if (file.files.length === 1) {
// 단일 파일: 직접 다운로드
await this.downloadSingleFileData(file.files[0]);
this.showNotification(`파일 다운로드 완료: ${file.files[0].name || file.files[0].original_name}`, 'success');
await this.downloadSingleFile(id, 0);
} else {
// 다중 파일: localStorage에서 base64 데이터를 각각 다운로드
for (const fileData of file.files) {
await this.downloadSingleFileData(fileData);
// 다중 파일: 각각 다운로드
for (let i = 0; i < file.files.length; i++) {
await this.downloadSingleFile(id, i);
// 짧은 딜레이를 추가하여 브라우저가 다운로드를 처리할 시간을 줌
await new Promise(resolve => setTimeout(resolve, 500));
}
this.showNotification(`${file.files.length}개 파일 다운로드 완료`, 'success');
}
@@ -314,41 +336,134 @@ class FileManager {
}
}
async downloadSingleFile(fileId, fileIndex) {
const file = this.files.find(f => f.id === fileId);
if (!file) {
this.showNotification('파일을 찾을 수 없습니다.', 'error');
return;
}
if (!file.files || !file.files[fileIndex]) {
this.showNotification('첨부파일을 찾을 수 없습니다.', 'error');
return;
}
async downloadSingleFile(fileId, attachmentIndex) {
try {
const fileData = file.files[fileIndex];
await this.downloadSingleFileData(fileData);
this.showNotification(`파일 다운로드 완료: ${fileData.name || fileData.original_name}`, 'success');
// 다운로드 시작 로딩 표시
this.showLoading(true);
console.log('downloadSingleFile 호출됨:', fileId, attachmentIndex);
const file = this.files.find(f => f.id === fileId);
console.log('찾은 파일:', file);
if (!file || !file.files[attachmentIndex]) {
console.log('파일 또는 첨부파일을 찾을 수 없음');
throw new Error('파일을 찾을 수 없습니다.');
}
const attachmentId = file.files[attachmentIndex].id;
const downloadUrl = `/api/download/${fileId}/${attachmentId}`;
console.log('다운로드 URL:', downloadUrl);
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) {
console.error('개별 파일 다운로드 오류:', error);
console.error('downloadSingleFile 오류:', error);
this.showLoading(false);
this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
}
}
async downloadSingleFileData(fileData) {
if (fileData.data) {
// localStorage의 base64 데이터 다운로드
const link = document.createElement('a');
link.href = fileData.data;
link.download = fileData.name || fileData.original_name || 'file';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
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;
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') {
// 간단한 알림 표시
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
@@ -374,102 +489,15 @@ class FileManager {
}, 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) {
const div = document.createElement('div');
div.textContent = text;
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', () => {
fileManager = new FileManager();
publicViewer = new PublicFileViewer();
});

73
scripts/init-database.js Normal file
View 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
View 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
View 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
View 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

View File

@@ -39,6 +39,27 @@ header p {
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 {
@@ -46,6 +67,83 @@ header p {
display: flex;
justify-content: 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 {
@@ -243,6 +341,97 @@ header p {
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 {
background: rgba(255, 255, 255, 0.95);
padding: 30px;
@@ -466,6 +655,34 @@ header p {
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 {
display: flex;
flex-direction: column;
@@ -495,11 +712,10 @@ header p {
}
.attachment-file-icon {
font-size: 1.1rem;
width: auto;
text-align: left;
font-size: 0.9rem;
min-width: 16px;
text-align: center;
flex-shrink: 0;
margin-right: 1px;
}
.attachment-file-info {
@@ -512,12 +728,13 @@ header p {
}
.attachment-file-name {
font-weight: 500;
color: #374151;
flex: 1;
font-size: 0.75rem;
color: #475569;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8rem;
max-width: 120px;
}
.attachment-file-size {

248
synology-setup.md Normal file
View 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
View 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