Add complete Jaryo File Manager with Synology NAS deployment support
This commit is contained in:
@@ -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
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
|
||||
};
|
229
admin/index.html
229
admin/index.html
@@ -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>
|
2810
admin/script.js
2810
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>
|
||||
<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
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"
|
354
script.js
354
script.js
@@ -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
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;
|
||||
}
|
||||
|
||||
.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
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