Add complete Jaryo File Manager with Synology NAS deployment support

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

576
database/db-helper.js Normal file
View File

@@ -0,0 +1,576 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
class DatabaseHelper {
constructor() {
this.dbPath = path.join(__dirname, 'jaryo.db');
this.db = null;
}
// 데이터베이스 연결
connect() {
return new Promise((resolve, reject) => {
if (this.db) {
resolve(this.db);
return;
}
this.db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READWRITE, (err) => {
if (err) {
console.error('데이터베이스 연결 오류:', err.message);
reject(err);
} else {
console.log('✅ SQLite 데이터베이스 연결됨');
resolve(this.db);
}
});
});
}
// 데이터베이스 연결 종료
close() {
return new Promise((resolve, reject) => {
if (this.db) {
this.db.close((err) => {
if (err) {
reject(err);
} else {
this.db = null;
resolve();
}
});
} else {
resolve();
}
});
}
// 모든 파일 목록 가져오기
async getAllFiles(limit = 100, offset = 0) {
await this.connect();
return new Promise((resolve, reject) => {
// 더 간단한 쿼리로 변경 - 첨부파일은 별도 쿼리로 처리
const query = `
SELECT * FROM files
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
this.db.all(query, [limit, offset], async (err, rows) => {
if (err) {
reject(err);
return;
}
const files = [];
for (const row of rows) {
const file = {
id: row.id,
title: row.title,
description: row.description,
category: row.category,
tags: row.tags ? JSON.parse(row.tags) : [],
user_id: row.user_id,
created_at: row.created_at,
updated_at: row.updated_at,
files: []
};
// 각 파일의 첨부파일을 별도로 조회
try {
const attachments = await this.getFileAttachments(row.id);
file.files = attachments;
} catch (attachmentError) {
console.warn('첨부파일 조회 오류:', attachmentError);
file.files = [];
}
files.push(file);
}
resolve(files);
});
});
}
// 파일의 첨부파일 목록 가져오기
async getFileAttachments(fileId) {
return new Promise((resolve, reject) => {
const query = 'SELECT * FROM file_attachments WHERE file_id = ?';
this.db.all(query, [fileId], (err, rows) => {
if (err) {
reject(err);
} else {
const attachments = rows.map(row => ({
id: row.id,
original_name: row.original_name,
file_name: row.file_name,
file_path: row.file_path,
file_size: row.file_size,
mime_type: row.mime_type,
name: row.original_name, // 호환성을 위해
size: row.file_size // 호환성을 위해
}));
resolve(attachments);
}
});
});
}
// 파일 검색
async searchFiles(searchTerm, category = null, limit = 100) {
await this.connect();
return new Promise((resolve, reject) => {
let query = `
SELECT * FROM files
WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)
`;
const params = [`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`];
if (category) {
query += ' AND category = ?';
params.push(category);
}
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(limit);
this.db.all(query, params, async (err, rows) => {
if (err) {
reject(err);
return;
}
const files = [];
for (const row of rows) {
const file = {
id: row.id,
title: row.title,
description: row.description,
category: row.category,
tags: row.tags ? JSON.parse(row.tags) : [],
user_id: row.user_id,
created_at: row.created_at,
updated_at: row.updated_at,
files: []
};
// 각 파일의 첨부파일을 별도로 조회
try {
const attachments = await this.getFileAttachments(row.id);
file.files = attachments;
} catch (attachmentError) {
console.warn('첨부파일 조회 오류:', attachmentError);
file.files = [];
}
files.push(file);
}
resolve(files);
});
});
}
// 새 파일 추가
async addFile(fileData) {
await this.connect();
return new Promise((resolve, reject) => {
const query = `
INSERT INTO files (id, title, description, category, tags, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`;
const params = [
fileData.id || this.generateId(),
fileData.title,
fileData.description || '',
fileData.category,
JSON.stringify(fileData.tags || []),
fileData.user_id || 'offline-user'
];
this.db.run(query, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: params[0], changes: this.changes });
}
});
});
}
// 파일 정보 수정
async updateFile(id, updates) {
await this.connect();
return new Promise((resolve, reject) => {
const setClause = [];
const params = [];
if (updates.title !== undefined) {
setClause.push('title = ?');
params.push(updates.title);
}
if (updates.description !== undefined) {
setClause.push('description = ?');
params.push(updates.description);
}
if (updates.category !== undefined) {
setClause.push('category = ?');
params.push(updates.category);
}
if (updates.tags !== undefined) {
setClause.push('tags = ?');
params.push(JSON.stringify(updates.tags));
}
setClause.push('updated_at = CURRENT_TIMESTAMP');
params.push(id);
const query = `UPDATE files SET ${setClause.join(', ')} WHERE id = ?`;
this.db.run(query, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
// 파일 삭제
async deleteFile(id) {
await this.connect();
return new Promise((resolve, reject) => {
// 첨부파일부터 삭제 (CASCADE가 있지만 명시적으로)
this.db.run('DELETE FROM file_attachments WHERE file_id = ?', [id], (err) => {
if (err) {
reject(err);
return;
}
// 파일 정보 삭제
this.db.run('DELETE FROM files WHERE id = ?', [id], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
});
}
// 첨부파일 추가
async addFileAttachment(fileId, attachmentData) {
await this.connect();
return new Promise((resolve, reject) => {
const query = `
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type, file_data)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const params = [
fileId,
attachmentData.original_name,
attachmentData.file_name || attachmentData.original_name,
attachmentData.file_path || '',
attachmentData.file_size || 0,
attachmentData.mime_type || '',
attachmentData.file_data || null
];
this.db.run(query, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, changes: this.changes });
}
});
});
}
// 첨부파일 삭제
async deleteFileAttachment(attachmentId) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'DELETE FROM file_attachments WHERE id = ?';
this.db.run(query, [attachmentId], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
// 카테고리 목록 가져오기
async getCategories() {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'SELECT * FROM categories ORDER BY is_default DESC, name ASC';
this.db.all(query, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
// 카테고리 추가
async addCategory(name) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'INSERT INTO categories (name) VALUES (?)';
this.db.run(query, [name], function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, changes: this.changes });
}
});
});
}
// 카테고리 수정
async updateCategory(id, name) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'UPDATE categories SET name = ? WHERE id = ?';
this.db.run(query, [name, id], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
// 카테고리 삭제
async deleteCategory(id) {
await this.connect();
return new Promise((resolve, reject) => {
// 해당 카테고리를 사용하는 파일들을 '기타'로 변경
this.db.serialize(() => {
this.db.run('UPDATE files SET category = "기타" WHERE category = (SELECT name FROM categories WHERE id = ?)', [id], (err) => {
if (err) {
reject(err);
return;
}
// 카테고리 삭제
this.db.run('DELETE FROM categories WHERE id = ?', [id], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
});
});
}
// 통계 정보 가져오기
async getStats() {
await this.connect();
return new Promise((resolve, reject) => {
const queries = [
'SELECT COUNT(*) as total_files FROM files',
'SELECT category, COUNT(*) as count FROM files GROUP BY category',
'SELECT COUNT(*) as total_attachments FROM file_attachments'
];
Promise.all(queries.map(query =>
new Promise((res, rej) => {
this.db.all(query, [], (err, rows) => {
if (err) rej(err);
else res(rows);
});
})
)).then(results => {
resolve({
total_files: results[0][0].total_files,
by_category: results[1],
total_attachments: results[2][0].total_attachments
});
}).catch(reject);
});
}
// 사용자 관련 메서드들
async getUserByEmail(email) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'SELECT * FROM users WHERE email = ? AND is_active = 1';
this.db.get(query, [email], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
async getUserById(id) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'SELECT * FROM users WHERE id = ? AND is_active = 1';
this.db.get(query, [id], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
async createUser(userData) {
await this.connect();
return new Promise((resolve, reject) => {
const query = `
INSERT INTO users (id, email, password_hash, name, role)
VALUES (?, ?, ?, ?, ?)
`;
const userId = this.generateId();
const params = [
userId,
userData.email,
userData.password_hash,
userData.name,
userData.role || 'user'
];
this.db.run(query, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: userId, changes: this.changes });
}
});
});
}
async updateUserLastLogin(userId) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?';
this.db.run(query, [userId], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async createSession(userId, sessionId, expiresAt) {
await this.connect();
return new Promise((resolve, reject) => {
const query = `
INSERT INTO user_sessions (id, user_id, expires_at)
VALUES (?, ?, ?)
`;
this.db.run(query, [sessionId, userId, expiresAt], function(err) {
if (err) {
reject(err);
} else {
resolve({ id: sessionId, changes: this.changes });
}
});
});
}
async getSession(sessionId) {
await this.connect();
return new Promise((resolve, reject) => {
const query = `
SELECT s.*, u.id as user_id, u.email, u.name, u.role
FROM user_sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.is_active = 1
`;
this.db.get(query, [sessionId], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
async deleteSession(sessionId) {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'DELETE FROM user_sessions WHERE id = ?';
this.db.run(query, [sessionId], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async cleanExpiredSessions() {
await this.connect();
return new Promise((resolve, reject) => {
const query = 'DELETE FROM user_sessions WHERE expires_at <= datetime("now")';
this.db.run(query, [], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
// ID 생성 헬퍼
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
}
}
module.exports = DatabaseHelper;

90
database/schema.sql Normal file
View File

@@ -0,0 +1,90 @@
-- 자료실 SQLite 데이터베이스 스키마
-- 파일: database/schema.sql
-- 파일 정보 테이블
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT '기타',
tags TEXT, -- JSON 배열로 저장
user_id TEXT DEFAULT 'offline-user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 파일 첨부 정보 테이블
CREATE TABLE IF NOT EXISTS file_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id TEXT NOT NULL,
original_name TEXT NOT NULL,
file_name TEXT NOT NULL, -- 실제 저장된 파일명
file_path TEXT NOT NULL, -- 파일 저장 경로
file_size INTEGER DEFAULT 0,
mime_type TEXT,
file_data TEXT, -- base64 인코딩된 파일 데이터 (소용량 파일용)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);
-- 카테고리 테이블
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
is_default INTEGER DEFAULT 0, -- 기본 카테고리 여부
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'user', -- 'admin', 'user'
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
);
-- 세션 테이블
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 기본 카테고리 데이터 삽입
INSERT OR IGNORE INTO categories (name, is_default) VALUES
('문서', 1),
('이미지', 1),
('동영상', 1),
('프레젠테이션', 1),
('기타', 1);
-- 기본 관리자 계정 생성 (비밀번호: admin123)
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES
('admin-001', 'admin@jaryo.com', '$2b$10$0u/zxn1NL4n6t.hNs1eMh.12tXEv9HYgf4cPRXKT3aX97mOKR01Du', '관리자', 'admin');
-- 샘플 데이터 삽입
INSERT OR IGNORE INTO files (id, title, description, category, tags, created_at, updated_at) VALUES
('sample-1', '프로젝트 계획서', '2024년 상반기 주요 프로젝트 계획서입니다.', '문서', '["계획서", "프로젝트", "2024"]', datetime('now'), datetime('now')),
('sample-2', '회의 자료', '월간 정기 회의 자료 모음입니다.', '프레젠테이션', '["회의", "정기회의"]', datetime('now'), datetime('now')),
('sample-3', '시스템 스크린샷', '새로운 관리 시스템 화면 캡처 이미지들입니다.', '이미지', '["시스템", "스크린샷"]', datetime('now'), datetime('now'));
-- 샘플 첨부파일 데이터
INSERT OR IGNORE INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type) VALUES
('sample-1', 'project_plan_2024.pdf', 'project_plan_2024.pdf', 'uploads/project_plan_2024.pdf', 1024000, 'application/pdf'),
('sample-2', 'meeting_slides.pptx', 'meeting_slides.pptx', 'uploads/meeting_slides.pptx', 2048000, 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
('sample-3', 'admin_screenshot1.png', 'admin_screenshot1.png', 'uploads/admin_screenshot1.png', 512000, 'image/png'),
('sample-3', 'admin_screenshot2.png', 'admin_screenshot2.png', 'uploads/admin_screenshot2.png', 768000, 'image/png');
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_files_category ON files(category);
CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at);
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
CREATE INDEX IF NOT EXISTS idx_file_attachments_file_id ON file_attachments(file_id);