/** * CloudSync Enterprise File Manager - Express Backend * Filesystem-backed file operations + SQLite for users, shares, and activity. * No MongoDB dependency. */ require('dotenv').config(); const express = require('express'); const Database = require('better-sqlite3'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const multer = require('multer'); const mime = require('mime-types'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const archiver = require('archiver'); // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- const PORT = process.env.PORT || 8080; const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '/nas'); const DB_PATH = process.env.DB_PATH || '/config/cloudsync.db'; const JWT_SECRET = process.env.JWT_SECRET || 'cloudsync_secret_key'; const JWT_ALGORITHM = 'HS256'; const JWT_EXPIRATION_HOURS = 24; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'alexz'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.SMB_PASSWORD || 'admin123'; // --------------------------------------------------------------------------- // SQLite setup // --------------------------------------------------------------------------- let sqlDb; function initDatabase() { const dbDir = path.dirname(DB_PATH); fs.mkdirSync(dbDir, { recursive: true }); sqlDb = new Database(DB_PATH); sqlDb.pragma('journal_mode = WAL'); sqlDb.pragma('foreign_keys = ON'); sqlDb.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT 'user', storage_quota INTEGER DEFAULT 10737418240, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS shares ( id TEXT PRIMARY KEY, token TEXT UNIQUE NOT NULL, file_path TEXT NOT NULL, owner_id TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, download_count INTEGER DEFAULT 0, password_hash TEXT ); CREATE TABLE IF NOT EXISTS activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, action TEXT, resource_type TEXT, resource_name TEXT, details TEXT, created_at TEXT NOT NULL ); `); } // --------------------------------------------------------------------------- // Multer setup (disk storage into UPLOAD_DIR) // --------------------------------------------------------------------------- const upload = multer({ storage: multer.memoryStorage() }); // --------------------------------------------------------------------------- // Express app // --------------------------------------------------------------------------- const app = express(); // CORS app.use(cors()); // JSON body parser app.use(express.json()); // --------------------------------------------------------------------------- // Helper: base64url encode / decode for file IDs // --------------------------------------------------------------------------- function encodeFileId(relativePath) { if (!relativePath || relativePath === '' || relativePath === '.') return null; return Buffer.from(relativePath, 'utf8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } function decodeFileId(fileId) { if (!fileId) return ''; let b64 = fileId.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4; if (pad) b64 += '='.repeat(4 - pad); return Buffer.from(b64, 'base64').toString('utf8'); } // --------------------------------------------------------------------------- // Helper: Path security - prevent directory traversal // --------------------------------------------------------------------------- function safePath(relativePath) { const resolved = path.resolve(UPLOAD_DIR, relativePath); if (!resolved.startsWith(UPLOAD_DIR)) { return null; } return resolved; } function safeRelativePath(relativePath) { if (!relativePath) return ''; const normalized = path.normalize(relativePath); if (normalized.startsWith('..') || path.isAbsolute(normalized)) { return null; } const full = path.resolve(UPLOAD_DIR, normalized); if (!full.startsWith(UPLOAD_DIR)) { return null; } return normalized; } // --------------------------------------------------------------------------- // Helper: Password hashing // --------------------------------------------------------------------------- function hashPassword(password) { return bcrypt.hashSync(password, 10); } function verifyPassword(plainPassword, hashedPassword) { return bcrypt.compareSync(plainPassword, hashedPassword); } // --------------------------------------------------------------------------- // Helper: JWT // --------------------------------------------------------------------------- function createAccessToken(data) { const payload = { ...data }; payload.exp = Math.floor(Date.now() / 1000) + JWT_EXPIRATION_HOURS * 3600; return jwt.sign(payload, JWT_SECRET, { algorithm: JWT_ALGORITHM }); } function decodeToken(token) { try { return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }); } catch (_err) { return null; } } // --------------------------------------------------------------------------- // Middleware: Auth // --------------------------------------------------------------------------- function requireAuth(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ detail: 'Not authenticated' }); } const token = authHeader.slice(7); const payload = decodeToken(token); if (!payload) { return res.status(401).json({ detail: 'Invalid or expired token' }); } const user = sqlDb.prepare('SELECT * FROM users WHERE id = ?').get(payload.user_id); if (!user) { return res.status(401).json({ detail: 'User not found' }); } req.user = user; next(); } function requireAdmin(req, res, next) { if (req.user.role !== 'admin') { return res.status(403).json({ detail: 'Admin access required' }); } next(); } // --------------------------------------------------------------------------- // Helper: Activity logging // --------------------------------------------------------------------------- function logActivity(userId, action, resourceType, resourceName, details) { sqlDb.prepare( 'INSERT INTO activity (user_id, action, resource_type, resource_name, details, created_at) VALUES (?, ?, ?, ?, ?, ?)' ).run(userId, action, resourceType, resourceName, details || null, new Date().toISOString()); } // --------------------------------------------------------------------------- // Helper: Walk directory to compute size // --------------------------------------------------------------------------- function getDirSize(dirPath) { let total = 0; if (!fs.existsSync(dirPath)) return 0; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { total += getDirSize(fullPath); } else if (entry.isFile()) { try { total += fs.statSync(fullPath).size; } catch (_e) { // skip files we cannot stat } } } return total; } // --------------------------------------------------------------------------- // Helper: Count files and folders in a directory tree // --------------------------------------------------------------------------- function countFilesAndFolders(dirPath) { let files = 0; let folders = 0; if (!fs.existsSync(dirPath)) return { files, folders }; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { folders++; const sub = countFilesAndFolders(fullPath); files += sub.files; folders += sub.folders; } else if (entry.isFile()) { files++; } } return { files, folders }; } // --------------------------------------------------------------------------- // Helper: Get user storage used // --------------------------------------------------------------------------- function getUserStorageUsed(_userId) { // In single-directory mode, walk UPLOAD_DIR return getDirSize(UPLOAD_DIR); } // --------------------------------------------------------------------------- // Helper: File icon // --------------------------------------------------------------------------- function getFileIcon(filename, isFolder) { if (isFolder) return 'folder'; const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : ''; const icons = { image: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico'], video: ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm'], audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'], archive: ['zip', 'tar', 'gz', 'rar', '7z', 'bz2'], code: ['js', 'ts', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'php', 'html', 'css', 'json', 'xml', 'yaml', 'sh', 'sql'], pdf: ['pdf'], document: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'txt', 'md', 'rtf', 'csv'], }; for (const [iconType, extensions] of Object.entries(icons)) { if (extensions.includes(ext)) return iconType; } return 'file'; } // --------------------------------------------------------------------------- // Helper: Build file response from fs.stat result // --------------------------------------------------------------------------- function formatFileResponse(relativePath, stat, parentRelPath) { const name = path.basename(relativePath); const isFolder = stat.isDirectory(); const mimeType = isFolder ? '' : (mime.lookup(name) || 'application/octet-stream'); const id = encodeFileId(relativePath); const parentId = parentRelPath ? encodeFileId(parentRelPath) : null; // Build path string with leading slash const displayPath = '/' + relativePath.replace(/\\/g, '/'); return { id, name, is_folder: isFolder, size: isFolder ? getDirSize(path.join(UPLOAD_DIR, relativePath)) : stat.size, mime_type: mimeType, icon: getFileIcon(name, isFolder), parent_id: parentId, path: displayPath, created_at: stat.birthtime.toISOString(), modified_at: stat.mtime.toISOString(), owner_id: '', }; } // --------------------------------------------------------------------------- // Helper: Search files recursively // --------------------------------------------------------------------------- function searchFiles(dirPath, query, basePath) { const results = []; if (!fs.existsSync(dirPath)) return results; const lowerQuery = query.toLowerCase(); const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const relativePath = basePath ? path.join(basePath, entry.name) : entry.name; const fullPath = path.join(dirPath, entry.name); if (entry.name.toLowerCase().includes(lowerQuery)) { try { const stat = fs.statSync(fullPath); const parentRel = basePath || ''; results.push(formatFileResponse(relativePath, stat, parentRel || null)); } catch (_e) { // skip } } if (entry.isDirectory()) { results.push(...searchFiles(fullPath, query, relativePath)); } } return results; } // --------------------------------------------------------------------------- // Helper: Recursive copy for directories // --------------------------------------------------------------------------- function copyRecursive(src, dest) { const stat = fs.statSync(src); if (stat.isDirectory()) { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src); for (const entry of entries) { copyRecursive(path.join(src, entry), path.join(dest, entry)); } } else { fs.copyFileSync(src, dest); } } // --------------------------------------------------------------------------- // Helper: Add folder to archive from filesystem // --------------------------------------------------------------------------- function addFolderToArchive(archive, folderAbsPath, archivePath) { if (!fs.existsSync(folderAbsPath)) return; const entries = fs.readdirSync(folderAbsPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(folderAbsPath, entry.name); if (entry.isDirectory()) { addFolderToArchive(archive, fullPath, archivePath + entry.name + '/'); } else if (entry.isFile()) { archive.file(fullPath, { name: archivePath + entry.name }); } } } // --------------------------------------------------------------------------- // Helper: Compute type breakdown from filesystem // --------------------------------------------------------------------------- function getTypeBreakdown(dirPath) { const breakdown = {}; if (!fs.existsSync(dirPath)) return breakdown; function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walk(fullPath); } else if (entry.isFile()) { try { const stat = fs.statSync(fullPath); const mimeType = mime.lookup(entry.name) || 'application/octet-stream'; const category = mimeType.split('/')[0] || 'other'; if (!breakdown[category]) { breakdown[category] = { count: 0, size: 0 }; } breakdown[category].count++; breakdown[category].size += stat.size; } catch (_e) { // skip } } } } walk(dirPath); return breakdown; } // ========================================================================= // API ROUTES // ========================================================================= // --------------------------------------------------------------------------- // Health // --------------------------------------------------------------------------- app.get('/api/health', (_req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // --------------------------------------------------------------------------- // Auth Routes // --------------------------------------------------------------------------- app.post('/api/auth/login', (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ detail: 'Username and password are required' }); } const user = sqlDb.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !verifyPassword(password, user.password_hash)) { return res.status(401).json({ detail: 'Invalid credentials' }); } const token = createAccessToken({ user_id: user.id, role: user.role }); logActivity(user.id, 'login', 'auth', user.username); const storageUsed = getUserStorageUsed(user.id); return res.json({ token, user: { id: user.id, username: user.username, name: user.name, role: user.role, storage_quota: user.storage_quota || 10737418240, storage_used: storageUsed, }, }); } catch (err) { console.error('Login error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.get('/api/auth/me', requireAuth, (req, res) => { try { const storageUsed = getUserStorageUsed(req.user.id); return res.json({ id: req.user.id, username: req.user.username, name: req.user.name, role: req.user.role, storage_quota: req.user.storage_quota || 10737418240, storage_used: storageUsed, created_at: req.user.created_at || '', }); } catch (err) { console.error('Get me error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // User Management Routes (Admin only) // --------------------------------------------------------------------------- app.get('/api/users', requireAuth, requireAdmin, (_req, res) => { try { const users = sqlDb.prepare('SELECT id, username, name, role, storage_quota, created_at FROM users').all(); const result = users.map((u) => ({ id: u.id, username: u.username, name: u.name, role: u.role, storage_quota: u.storage_quota || 10737418240, storage_used: getUserStorageUsed(u.id), created_at: u.created_at || '', })); return res.json(result); } catch (err) { console.error('List users error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/users', requireAuth, requireAdmin, (req, res) => { try { const { username, password, name, role, storage_quota } = req.body; if (!username || !password || !name) { return res.status(400).json({ detail: 'Username, password, and name are required' }); } const existing = sqlDb.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existing) { return res.status(400).json({ detail: 'Username already exists' }); } const userId = crypto.randomUUID(); sqlDb.prepare( 'INSERT INTO users (id, username, password_hash, name, role, storage_quota, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).run(userId, username, hashPassword(password), name, role || 'user', storage_quota || 10737418240, new Date().toISOString()); logActivity(req.user.id, 'create', 'user', username); return res.status(201).json({ id: userId, message: 'User created successfully' }); } catch (err) { console.error('Create user error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.put('/api/users/:userId', requireAuth, requireAdmin, (req, res) => { try { const { userId } = req.params; const { name, role, password, storage_quota } = req.body; const user = sqlDb.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) { return res.status(404).json({ detail: 'User not found' }); } const sets = []; const params = []; if (name !== undefined && name !== null) { sets.push('name = ?'); params.push(name); } if (role !== undefined && role !== null) { sets.push('role = ?'); params.push(role); } if (storage_quota !== undefined && storage_quota !== null) { sets.push('storage_quota = ?'); params.push(storage_quota); } if (password !== undefined && password !== null) { sets.push('password_hash = ?'); params.push(hashPassword(password)); } if (sets.length > 0) { params.push(userId); sqlDb.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...params); } logActivity(req.user.id, 'update', 'user', user.username); return res.json({ message: 'User updated successfully' }); } catch (err) { console.error('Update user error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.delete('/api/users/:userId', requireAuth, requireAdmin, (req, res) => { try { const { userId } = req.params; if (userId === req.user.id) { return res.status(400).json({ detail: 'Cannot delete yourself' }); } const user = sqlDb.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) { return res.status(404).json({ detail: 'User not found' }); } sqlDb.prepare('DELETE FROM users WHERE id = ?').run(userId); logActivity(req.user.id, 'delete', 'user', user.username); return res.json({ message: 'User deleted successfully' }); } catch (err) { console.error('Delete user error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // File Management Routes // --------------------------------------------------------------------------- app.get('/api/files', requireAuth, (req, res) => { try { const { parent_id, search } = req.query; // Search mode if (search) { const results = searchFiles(UPLOAD_DIR, search, ''); results.sort((a, b) => { if (a.is_folder !== b.is_folder) return a.is_folder ? -1 : 1; return a.name.localeCompare(b.name); }); return res.json(results); } // Directory listing let relDir = ''; if (parent_id) { relDir = decodeFileId(parent_id); const safe = safeRelativePath(relDir); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } relDir = safe; } const absDir = path.join(UPLOAD_DIR, relDir); if (!absDir.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) { return res.status(404).json({ detail: 'Directory not found' }); } const entries = fs.readdirSync(absDir, { withFileTypes: true }); const result = []; for (const entry of entries) { const relativePath = relDir ? path.join(relDir, entry.name) : entry.name; const fullPath = path.join(absDir, entry.name); try { const stat = fs.statSync(fullPath); result.push(formatFileResponse(relativePath, stat, relDir || null)); } catch (_e) { // skip files we cannot stat } } // Sort: folders first, then alphabetical result.sort((a, b) => { if (a.is_folder !== b.is_folder) return a.is_folder ? -1 : 1; return a.name.localeCompare(b.name); }); return res.json(result); } catch (err) { console.error('List files error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.get('/api/files/:fileId', requireAuth, (req, res) => { try { const { fileId } = req.params; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const stat = fs.statSync(absPath); const parentRel = path.dirname(safe); return res.json(formatFileResponse(safe, stat, parentRel === '.' ? null : parentRel)); } catch (err) { console.error('Get file error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/files/folder', requireAuth, (req, res) => { try { const { name, parent_id } = req.body; if (!name) { return res.status(400).json({ detail: 'Folder name is required' }); } // Validate folder name if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') { return res.status(400).json({ detail: 'Invalid folder name' }); } let parentRel = ''; if (parent_id) { parentRel = decodeFileId(parent_id); const safe = safeRelativePath(parentRel); if (safe === null) { return res.status(400).json({ detail: 'Invalid parent path' }); } parentRel = safe; const parentAbs = path.join(UPLOAD_DIR, parentRel); if (!fs.existsSync(parentAbs) || !fs.statSync(parentAbs).isDirectory()) { return res.status(404).json({ detail: 'Parent folder not found' }); } } const newRelPath = parentRel ? path.join(parentRel, name) : name; const newAbsPath = path.join(UPLOAD_DIR, newRelPath); // Security check if (!newAbsPath.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } if (fs.existsSync(newAbsPath)) { return res.status(400).json({ detail: 'Item with this name already exists' }); } fs.mkdirSync(newAbsPath, { recursive: true }); logActivity(req.user.id, 'create', 'folder', name); const folderId = encodeFileId(newRelPath); return res.status(201).json({ id: folderId, message: 'Folder created successfully' }); } catch (err) { console.error('Create folder error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/files/upload', requireAuth, upload.single('file'), (req, res) => { try { if (!req.file) { return res.status(400).json({ detail: 'No file provided' }); } const parentId = req.body.parent_id || null; // Check storage quota const storageUsed = getUserStorageUsed(req.user.id); const storageQuota = req.user.storage_quota || 10737418240; const fileSize = req.file.size; if (storageUsed + fileSize > storageQuota) { return res.status(400).json({ detail: 'Storage quota exceeded' }); } let parentRel = ''; if (parentId) { parentRel = decodeFileId(parentId); const safe = safeRelativePath(parentRel); if (safe === null) { return res.status(400).json({ detail: 'Invalid parent path' }); } parentRel = safe; const parentAbs = path.join(UPLOAD_DIR, parentRel); if (!fs.existsSync(parentAbs) || !fs.statSync(parentAbs).isDirectory()) { return res.status(404).json({ detail: 'Parent folder not found' }); } } const fileName = req.file.originalname; const newRelPath = parentRel ? path.join(parentRel, fileName) : fileName; const newAbsPath = path.join(UPLOAD_DIR, newRelPath); // Security check if (!newAbsPath.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } // Write file to disk fs.writeFileSync(newAbsPath, req.file.buffer); logActivity(req.user.id, 'upload', 'file', fileName); const fileIdEncoded = encodeFileId(newRelPath); return res.json({ id: fileIdEncoded, message: 'File uploaded successfully', size: fileSize }); } catch (err) { console.error('Upload error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.put('/api/files/:fileId/rename', requireAuth, (req, res) => { try { const { fileId } = req.params; const { new_name } = req.body; if (!new_name) { return res.status(400).json({ detail: 'new_name is required' }); } // Validate new name if (new_name.includes('/') || new_name.includes('\\') || new_name === '.' || new_name === '..') { return res.status(400).json({ detail: 'Invalid file name' }); } const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const parentDir = path.dirname(safe); const newRelPath = parentDir === '.' ? new_name : path.join(parentDir, new_name); const newAbsPath = path.join(UPLOAD_DIR, newRelPath); if (!newAbsPath.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } if (fs.existsSync(newAbsPath)) { return res.status(400).json({ detail: 'Item with this name already exists' }); } const oldName = path.basename(safe); const isFolder = fs.statSync(absPath).isDirectory(); fs.renameSync(absPath, newAbsPath); // Update any shares that reference the old path const sharesWithOldPath = sqlDb.prepare('SELECT * FROM shares WHERE file_path = ? OR file_path LIKE ?').all(safe, safe + '/%'); for (const share of sharesWithOldPath) { const updatedPath = share.file_path === safe ? newRelPath : newRelPath + share.file_path.slice(safe.length); sqlDb.prepare('UPDATE shares SET file_path = ? WHERE id = ?').run(updatedPath, share.id); } const resourceType = isFolder ? 'folder' : 'file'; logActivity(req.user.id, 'rename', resourceType, `${oldName} -> ${new_name}`); return res.json({ message: 'Renamed successfully' }); } catch (err) { console.error('Rename error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.put('/api/files/:fileId/move', requireAuth, (req, res) => { try { const { fileId } = req.params; const { destination_id } = req.body; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } let destRel = ''; if (destination_id) { destRel = decodeFileId(destination_id); const safeDest = safeRelativePath(destRel); if (safeDest === null) { return res.status(400).json({ detail: 'Invalid destination path' }); } destRel = safeDest; const destAbs = path.join(UPLOAD_DIR, destRel); if (!fs.existsSync(destAbs) || !fs.statSync(destAbs).isDirectory()) { return res.status(404).json({ detail: 'Destination folder not found' }); } // Prevent moving folder into itself const isFolder = fs.statSync(absPath).isDirectory(); if (isFolder && destAbs.startsWith(absPath + path.sep)) { return res.status(400).json({ detail: 'Cannot move folder into itself' }); } if (isFolder && destAbs === absPath) { return res.status(400).json({ detail: 'Cannot move folder into itself' }); } } const fileName = path.basename(safe); const newRelPath = destRel ? path.join(destRel, fileName) : fileName; const newAbsPath = path.join(UPLOAD_DIR, newRelPath); if (!newAbsPath.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } fs.renameSync(absPath, newAbsPath); // Update shares const sharesWithOldPath = sqlDb.prepare('SELECT * FROM shares WHERE file_path = ? OR file_path LIKE ?').all(safe, safe + '/%'); for (const share of sharesWithOldPath) { const updatedPath = share.file_path === safe ? newRelPath : newRelPath + share.file_path.slice(safe.length); sqlDb.prepare('UPDATE shares SET file_path = ? WHERE id = ?').run(updatedPath, share.id); } const isFolder = fs.statSync(newAbsPath).isDirectory(); const resourceType = isFolder ? 'folder' : 'file'; logActivity(req.user.id, 'move', resourceType, fileName); return res.json({ message: 'Moved successfully' }); } catch (err) { console.error('Move error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/files/:fileId/copy', requireAuth, (req, res) => { try { const { fileId } = req.params; const { destination_id } = req.body; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } let destRel = ''; if (destination_id) { destRel = decodeFileId(destination_id); const safeDest = safeRelativePath(destRel); if (safeDest === null) { return res.status(400).json({ detail: 'Invalid destination path' }); } destRel = safeDest; const destAbs = path.join(UPLOAD_DIR, destRel); if (!fs.existsSync(destAbs) || !fs.statSync(destAbs).isDirectory()) { return res.status(404).json({ detail: 'Destination folder not found' }); } } const stat = fs.statSync(absPath); // Check storage quota const fileSize = stat.isDirectory() ? getDirSize(absPath) : stat.size; const storageUsed = getUserStorageUsed(req.user.id); if (storageUsed + fileSize > (req.user.storage_quota || 10737418240)) { return res.status(400).json({ detail: 'Storage quota exceeded' }); } const fileName = path.basename(safe); const newRelPath = destRel ? path.join(destRel, fileName) : fileName; const newAbsPath = path.join(UPLOAD_DIR, newRelPath); if (!newAbsPath.startsWith(UPLOAD_DIR)) { return res.status(400).json({ detail: 'Invalid path' }); } // If destination already exists, create a copy with suffix let finalAbsPath = newAbsPath; let finalRelPath = newRelPath; if (fs.existsSync(newAbsPath)) { const ext = path.extname(fileName); const base = path.basename(fileName, ext); const parentDir = destRel || ''; let counter = 1; while (fs.existsSync(finalAbsPath)) { const newName = `${base} (${counter})${ext}`; finalRelPath = parentDir ? path.join(parentDir, newName) : newName; finalAbsPath = path.join(UPLOAD_DIR, finalRelPath); counter++; } } copyRecursive(absPath, finalAbsPath); logActivity(req.user.id, 'copy', stat.isDirectory() ? 'folder' : 'file', fileName); const newId = encodeFileId(finalRelPath); return res.json({ id: newId, message: 'Copied successfully' }); } catch (err) { console.error('Copy error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.delete('/api/files/:fileId', requireAuth, (req, res) => { try { const { fileId } = req.params; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const stat = fs.statSync(absPath); const name = path.basename(safe); const resourceType = stat.isDirectory() ? 'folder' : 'file'; fs.rmSync(absPath, { recursive: true, force: true }); // Clean up shares referencing this path sqlDb.prepare('DELETE FROM shares WHERE file_path = ? OR file_path LIKE ?').run(safe, safe + '/%'); logActivity(req.user.id, 'delete', resourceType, name); return res.json({ message: 'Deleted successfully' }); } catch (err) { console.error('Delete file error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/files/bulk-delete', requireAuth, (req, res) => { try { const fileIds = req.body; if (!Array.isArray(fileIds)) { return res.status(400).json({ detail: 'Request body must be an array of file IDs' }); } let deleted = 0; for (const fileId of fileIds) { const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) continue; const absPath = path.join(UPLOAD_DIR, safe); if (fs.existsSync(absPath)) { fs.rmSync(absPath, { recursive: true, force: true }); sqlDb.prepare('DELETE FROM shares WHERE file_path = ? OR file_path LIKE ?').run(safe, safe + '/%'); deleted++; } } logActivity(req.user.id, 'bulk_delete', 'files', `${deleted} items`); return res.json({ message: `Deleted ${deleted} items` }); } catch (err) { console.error('Bulk delete error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // Download / Preview Routes // --------------------------------------------------------------------------- app.get('/api/files/:fileId/download', requireAuth, (req, res) => { try { const { fileId } = req.params; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const stat = fs.statSync(absPath); if (stat.isDirectory()) { return res.status(400).json({ detail: 'Cannot download folders directly' }); } logActivity(req.user.id, 'download', 'file', path.basename(safe)); const mimeType = mime.lookup(safe) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(path.basename(safe))}"`); res.setHeader('Content-Length', stat.size); const readStream = fs.createReadStream(absPath); readStream.pipe(res); } catch (err) { console.error('Download error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.get('/api/files/:fileId/preview', requireAuth, (req, res) => { try { const { fileId } = req.params; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const stat = fs.statSync(absPath); if (stat.isDirectory()) { return res.status(400).json({ detail: 'Cannot preview folders' }); } const mimeType = mime.lookup(safe) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(path.basename(safe))}"`); res.setHeader('Content-Length', stat.size); const readStream = fs.createReadStream(absPath); readStream.pipe(res); } catch (err) { console.error('Preview error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // Breadcrumb Route // --------------------------------------------------------------------------- app.get('/api/files/:fileId/breadcrumb', requireAuth, (req, res) => { try { const { fileId } = req.params; const relPath = decodeFileId(fileId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid path' }); } // Split relative path into segments and build breadcrumb const parts = safe.split(path.sep).filter(Boolean); const breadcrumb = []; let accumulated = ''; for (const part of parts) { accumulated = accumulated ? path.join(accumulated, part) : part; breadcrumb.push({ id: encodeFileId(accumulated), name: part, }); } return res.json(breadcrumb); } catch (err) { console.error('Breadcrumb error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // Share Routes // --------------------------------------------------------------------------- app.post('/api/shares', requireAuth, (req, res) => { try { const { file_id, expires_in_hours, password } = req.body; if (!file_id) { return res.status(400).json({ detail: 'file_id is required' }); } // Decode file_id to a relative path const relPath = decodeFileId(file_id); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: 'Invalid file path' }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const shareToken = crypto .createHash('sha256') .update(`${crypto.randomUUID()}${Date.now()}`) .digest('hex') .slice(0, 32); const hoursToExpire = expires_in_hours || 24; const expiresAt = new Date(Date.now() + hoursToExpire * 3600 * 1000); const shareId = crypto.randomUUID(); const passwordHash = password ? hashPassword(password) : null; sqlDb.prepare( 'INSERT INTO shares (id, token, file_path, owner_id, expires_at, created_at, download_count, password_hash) VALUES (?, ?, ?, ?, ?, ?, 0, ?)' ).run(shareId, shareToken, safe, req.user.id, expiresAt.toISOString(), new Date().toISOString(), passwordHash); logActivity(req.user.id, 'share', 'file', path.basename(safe)); return res.json({ id: shareId, token: shareToken, expires_at: expiresAt.toISOString(), has_password: !!password, }); } catch (err) { console.error('Create share error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.get('/api/shares', requireAuth, (req, res) => { try { const shares = sqlDb.prepare('SELECT * FROM shares WHERE owner_id = ?').all(req.user.id); const result = []; for (const share of shares) { const absPath = path.join(UPLOAD_DIR, share.file_path); if (fs.existsSync(absPath)) { const fileName = path.basename(share.file_path); result.push({ id: share.id, token: share.token, file_name: fileName, file_id: encodeFileId(share.file_path), expires_at: share.expires_at, created_at: share.created_at, download_count: share.download_count || 0, has_password: share.password_hash !== null, }); } } return res.json(result); } catch (err) { console.error('List shares error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.delete('/api/shares/:shareId', requireAuth, (req, res) => { try { const { shareId } = req.params; const result = sqlDb.prepare('DELETE FROM shares WHERE id = ? AND owner_id = ?').run(shareId, req.user.id); if (result.changes === 0) { return res.status(404).json({ detail: 'Share not found' }); } return res.json({ message: 'Share deleted successfully' }); } catch (err) { console.error('Delete share error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // Public share routes (no auth required) app.get('/api/share/:token', (req, res) => { try { const { token } = req.params; const share = sqlDb.prepare('SELECT * FROM shares WHERE token = ?').get(token); if (!share) { return res.status(404).json({ detail: 'Share not found' }); } const expiresAt = new Date(share.expires_at); if (new Date() > expiresAt) { return res.status(410).json({ detail: 'Share link has expired' }); } const absPath = path.join(UPLOAD_DIR, share.file_path); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found' }); } const stat = fs.statSync(absPath); const fileName = path.basename(share.file_path); const mimeType = mime.lookup(fileName) || 'application/octet-stream'; return res.json({ name: fileName, size: stat.size, mime_type: mimeType, requires_password: share.password_hash !== null, }); } catch (err) { console.error('Get shared file info error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); app.post('/api/share/:token/download', (req, res) => { try { const { token } = req.params; const { password } = req.query; const share = sqlDb.prepare('SELECT * FROM shares WHERE token = ?').get(token); if (!share) { return res.status(404).json({ detail: 'Share not found' }); } const expiresAt = new Date(share.expires_at); if (new Date() > expiresAt) { return res.status(410).json({ detail: 'Share link has expired' }); } if (share.password_hash) { if (!password || !verifyPassword(password, share.password_hash)) { return res.status(401).json({ detail: 'Invalid password' }); } } const absPath = path.join(UPLOAD_DIR, share.file_path); if (!absPath.startsWith(UPLOAD_DIR) || !fs.existsSync(absPath)) { return res.status(404).json({ detail: 'File not found on storage' }); } // Single-use: delete the share after download sqlDb.prepare('DELETE FROM shares WHERE id = ?').run(share.id); const fileName = path.basename(share.file_path); const mimeType = mime.lookup(fileName) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); const stat = fs.statSync(absPath); res.setHeader('Content-Length', stat.size); const readStream = fs.createReadStream(absPath); readStream.pipe(res); } catch (err) { console.error('Download shared file error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // Activity Route // --------------------------------------------------------------------------- app.get('/api/activity', requireAuth, (req, res) => { try { const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100); let activities; if (req.user.role === 'admin') { activities = sqlDb.prepare('SELECT * FROM activity ORDER BY created_at DESC LIMIT ?').all(limit); } else { activities = sqlDb.prepare('SELECT * FROM activity WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(req.user.id, limit); } const result = activities.map((activity) => { const activityUser = sqlDb.prepare('SELECT name FROM users WHERE id = ?').get(activity.user_id); return { id: String(activity.id), action: activity.action, resource_type: activity.resource_type, resource_name: activity.resource_name, details: activity.details || null, user_name: activityUser ? activityUser.name : 'Unknown', created_at: activity.created_at, }; }); return res.json(result); } catch (err) { console.error('Activity error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // Stats Route // --------------------------------------------------------------------------- app.get('/api/stats', requireAuth, (req, res) => { try { const storageUsed = getUserStorageUsed(req.user.id); const storageQuota = req.user.storage_quota || 10737418240; const counts = countFilesAndFolders(UPLOAD_DIR); const totalFiles = counts.files; const totalFolders = counts.folders; const activeShares = sqlDb.prepare( 'SELECT COUNT(*) as count FROM shares WHERE owner_id = ? AND expires_at > ?' ).get(req.user.id, new Date().toISOString()).count; const typeBreakdown = getTypeBreakdown(UPLOAD_DIR); return res.json({ storage_used: storageUsed, storage_quota: storageQuota, storage_percentage: storageQuota > 0 ? Math.round((storageUsed / storageQuota) * 1000) / 10 : 0, total_files: totalFiles, total_folders: totalFolders, active_shares: activeShares, type_breakdown: typeBreakdown, }); } catch (err) { console.error('Stats error:', err.message); return res.status(500).json({ detail: 'Internal server error' }); } }); // --------------------------------------------------------------------------- // ZIP Download Route // --------------------------------------------------------------------------- app.post('/api/download-zip', requireAuth, async (req, res) => { try { const { items } = req.body; if (!Array.isArray(items) || items.length === 0) { return res.status(400).json({ detail: 'items array is required and must not be empty' }); } // Verify all items exist const fileRecords = []; for (const itemId of items) { const relPath = decodeFileId(itemId); const safe = safeRelativePath(relPath); if (safe === null) { return res.status(400).json({ detail: `Invalid path for item: ${itemId}` }); } const absPath = path.join(UPLOAD_DIR, safe); if (!fs.existsSync(absPath)) { return res.status(404).json({ detail: `Item not found: ${itemId}` }); } const stat = fs.statSync(absPath); fileRecords.push({ name: path.basename(safe), absPath, isFolder: stat.isDirectory(), }); } // Determine filename for Content-Disposition const zipFilename = fileRecords.length === 1 ? encodeURIComponent(fileRecords[0].name.replace(/\.[^.]+$/, '') + '.zip') : 'download.zip'; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${zipFilename}"`); const archive = archiver('zip', { zlib: { level: 5 } }); archive.on('error', (err) => { console.error('Archive error:', err.message); if (!res.headersSent) { res.status(500).json({ detail: 'Archive creation failed' }); } }); archive.pipe(res); for (const file of fileRecords) { if (file.isFolder) { addFolderToArchive(archive, file.absPath, file.name + '/'); } else { archive.file(file.absPath, { name: file.name }); } } await archive.finalize(); logActivity(req.user.id, 'download_zip', 'files', `${fileRecords.length} items`); } catch (err) { console.error('ZIP download error:', err.message); if (!res.headersSent) { return res.status(500).json({ detail: 'Internal server error' }); } } }); // --------------------------------------------------------------------------- // Serve React build (SPA fallback) // --------------------------------------------------------------------------- app.use(express.static(path.join(__dirname, 'frontend/build'))); app.get('*', (_req, res) => { const indexPath = path.join(__dirname, 'frontend/build', 'index.html'); if (fs.existsSync(indexPath)) { return res.sendFile(indexPath); } return res.status(404).json({ detail: 'Not found' }); }); // --------------------------------------------------------------------------- // Seed admin user // --------------------------------------------------------------------------- function seedAdmin() { const count = sqlDb.prepare('SELECT COUNT(*) as count FROM users').get().count; if (count === 0) { const adminId = crypto.randomUUID(); sqlDb.prepare( 'INSERT INTO users (id, username, password_hash, name, role, storage_quota, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).run(adminId, ADMIN_USERNAME, hashPassword(ADMIN_PASSWORD), 'Administrator', 'admin', 107374182400, new Date().toISOString()); console.log(`Admin user created: ${ADMIN_USERNAME}`); } } // --------------------------------------------------------------------------- // Start server // --------------------------------------------------------------------------- function main() { try { // Ensure upload directory exists fs.mkdirSync(UPLOAD_DIR, { recursive: true }); // Initialize SQLite database initDatabase(); console.log(`SQLite database opened at ${DB_PATH}`); // Seed admin seedAdmin(); // Start listening app.listen(PORT, () => { console.log(`CloudSync server running on port ${PORT}`); }); } catch (err) { console.error('Failed to start server:', err.message); process.exit(1); } } main();