1498 lines
50 KiB
JavaScript
1498 lines
50 KiB
JavaScript
/**
|
|
* 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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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', hashPassword('admin123'), 'Administrator', 'admin', 107374182400, new Date().toISOString());
|
|
console.log('Default admin user created (admin/admin123)');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|