nas-samba v2.0.0: per-user home directories, Samba accounts, remove quotas
- Each user gets a Linux + Samba account and home dir at /nas/home/<username>/ - Web UI file browsing scoped to per-user directories - Creating/deleting users from web UI also manages system + Samba accounts - Replaced hardcoded [data] SMB share with [homes] (per-user automatic shares) - Removed hardcoded alexz user from entrypoint — server.js handles all user management - Removed storage quotas from backend and frontend - Existing users get home directories auto-created on startup (migration)
This commit is contained in:
@@ -17,12 +17,13 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '/nas');
|
||||
const HOME_BASE = '/nas/home';
|
||||
const DB_PATH = process.env.DB_PATH || '/config/cloudsync.db';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'cloudsync_secret_key';
|
||||
const JWT_ALGORITHM = 'HS256';
|
||||
@@ -90,7 +91,7 @@ function initDatabase() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multer setup (disk storage into UPLOAD_DIR)
|
||||
// Multer setup (disk storage into per-user dir)
|
||||
// ---------------------------------------------------------------------------
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
@@ -128,22 +129,22 @@ function decodeFileId(fileId) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Path security - prevent directory traversal
|
||||
// ---------------------------------------------------------------------------
|
||||
function safePath(relativePath) {
|
||||
const resolved = path.resolve(UPLOAD_DIR, relativePath);
|
||||
if (!resolved.startsWith(UPLOAD_DIR)) {
|
||||
function safePath(baseDir, relativePath) {
|
||||
const resolved = path.resolve(baseDir, relativePath);
|
||||
if (!resolved.startsWith(baseDir)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function safeRelativePath(relativePath) {
|
||||
function safeRelativePath(baseDir, 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)) {
|
||||
const full = path.resolve(baseDir, normalized);
|
||||
if (!full.startsWith(baseDir)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
@@ -160,6 +161,42 @@ function verifyPassword(plainPassword, hashedPassword) {
|
||||
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Username validation
|
||||
// ---------------------------------------------------------------------------
|
||||
function isValidUsername(username) {
|
||||
return /^[a-z_][a-z0-9_-]{0,31}$/.test(username);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: System user management (Linux + Samba)
|
||||
// ---------------------------------------------------------------------------
|
||||
function createSystemUser(username, password) {
|
||||
execSync(`id -u "${username}" >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin "${username}"`);
|
||||
const homeDir = path.join(HOME_BASE, username);
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
execSync(`chown "${username}":nogroup "${homeDir}"`);
|
||||
const escaped = password.replace(/'/g, "'\\''");
|
||||
execSync(`printf '%s\\n%s\\n' '${escaped}' '${escaped}' | smbpasswd -a -s "${username}"`);
|
||||
}
|
||||
|
||||
function deleteSystemUser(username) {
|
||||
execSync(`smbpasswd -x "${username}" 2>/dev/null || true`);
|
||||
execSync(`userdel "${username}" 2>/dev/null || true`);
|
||||
}
|
||||
|
||||
function updateSambaPassword(username, password) {
|
||||
const escaped = password.replace(/'/g, "'\\''");
|
||||
execSync(`printf '%s\\n%s\\n' '${escaped}' '${escaped}' | smbpasswd -s "${username}"`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Per-user directory
|
||||
// ---------------------------------------------------------------------------
|
||||
function getUserDir(username) {
|
||||
return path.join(HOME_BASE, username);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -264,9 +301,8 @@ function countFilesAndFolders(dirPath) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Get user storage used
|
||||
// ---------------------------------------------------------------------------
|
||||
function getUserStorageUsed(_userId) {
|
||||
// In single-directory mode, walk UPLOAD_DIR
|
||||
return getDirSize(UPLOAD_DIR);
|
||||
function getUserStorageUsed(username) {
|
||||
return getDirSize(getUserDir(username));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -293,7 +329,7 @@ function getFileIcon(filename, isFolder) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Build file response from fs.stat result
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatFileResponse(relativePath, stat, parentRelPath) {
|
||||
function formatFileResponse(userDir, relativePath, stat, parentRelPath) {
|
||||
const name = path.basename(relativePath);
|
||||
const isFolder = stat.isDirectory();
|
||||
const mimeType = isFolder ? '' : (mime.lookup(name) || 'application/octet-stream');
|
||||
@@ -307,7 +343,7 @@ function formatFileResponse(relativePath, stat, parentRelPath) {
|
||||
id,
|
||||
name,
|
||||
is_folder: isFolder,
|
||||
size: isFolder ? getDirSize(path.join(UPLOAD_DIR, relativePath)) : stat.size,
|
||||
size: isFolder ? getDirSize(path.join(userDir, relativePath)) : stat.size,
|
||||
mime_type: mimeType,
|
||||
icon: getFileIcon(name, isFolder),
|
||||
parent_id: parentId,
|
||||
@@ -321,7 +357,7 @@ function formatFileResponse(relativePath, stat, parentRelPath) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Search files recursively
|
||||
// ---------------------------------------------------------------------------
|
||||
function searchFiles(dirPath, query, basePath) {
|
||||
function searchFiles(dirPath, query, basePath, userDir) {
|
||||
const results = [];
|
||||
if (!fs.existsSync(dirPath)) return results;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
@@ -335,14 +371,14 @@ function searchFiles(dirPath, query, basePath) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
const parentRel = basePath || '';
|
||||
results.push(formatFileResponse(relativePath, stat, parentRel || null));
|
||||
results.push(formatFileResponse(userDir, relativePath, stat, parentRel || null));
|
||||
} catch (_e) {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...searchFiles(fullPath, query, relativePath));
|
||||
results.push(...searchFiles(fullPath, query, relativePath, userDir));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
@@ -460,8 +496,6 @@ app.post('/api/auth/login', (req, res) => {
|
||||
|
||||
logActivity(user.id, 'login', 'auth', user.username);
|
||||
|
||||
const storageUsed = getUserStorageUsed(user.id);
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
user: {
|
||||
@@ -469,8 +503,6 @@ app.post('/api/auth/login', (req, res) => {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
storage_quota: user.storage_quota || 10737418240,
|
||||
storage_used: storageUsed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -481,14 +513,11 @@ app.post('/api/auth/login', (req, res) => {
|
||||
|
||||
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) {
|
||||
@@ -502,14 +531,12 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
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 users = sqlDb.prepare('SELECT id, username, name, role, 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);
|
||||
@@ -521,11 +548,15 @@ app.get('/api/users', requireAuth, requireAdmin, (_req, res) => {
|
||||
|
||||
app.post('/api/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { username, password, name, role, storage_quota } = req.body;
|
||||
const { username, password, name, role } = req.body;
|
||||
if (!username || !password || !name) {
|
||||
return res.status(400).json({ detail: 'Username, password, and name are required' });
|
||||
}
|
||||
|
||||
if (!isValidUsername(username)) {
|
||||
return res.status(400).json({ detail: 'Username must be lowercase alphanumeric, start with letter or underscore, max 32 chars' });
|
||||
}
|
||||
|
||||
const existing = sqlDb.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) {
|
||||
return res.status(400).json({ detail: 'Username already exists' });
|
||||
@@ -534,7 +565,15 @@ app.post('/api/users', requireAuth, requireAdmin, (req, res) => {
|
||||
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());
|
||||
).run(userId, username, hashPassword(password), name, role || 'user', 10737418240, new Date().toISOString());
|
||||
|
||||
try {
|
||||
createSystemUser(username, password);
|
||||
} catch (sysErr) {
|
||||
console.error('Failed to create system user:', sysErr.message);
|
||||
sqlDb.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
return res.status(500).json({ detail: 'Failed to create system user' });
|
||||
}
|
||||
|
||||
logActivity(req.user.id, 'create', 'user', username);
|
||||
|
||||
@@ -548,7 +587,7 @@ app.post('/api/users', requireAuth, requireAdmin, (req, res) => {
|
||||
app.put('/api/users/:userId', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { name, role, password, storage_quota } = req.body;
|
||||
const { name, role, password } = req.body;
|
||||
|
||||
const user = sqlDb.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||
if (!user) {
|
||||
@@ -560,8 +599,15 @@ app.put('/api/users/:userId', requireAuth, requireAdmin, (req, res) => {
|
||||
|
||||
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 (password !== undefined && password !== null) {
|
||||
sets.push('password_hash = ?');
|
||||
params.push(hashPassword(password));
|
||||
try {
|
||||
updateSambaPassword(user.username, password);
|
||||
} catch (sysErr) {
|
||||
console.error('Failed to update Samba password:', sysErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
params.push(userId);
|
||||
@@ -591,6 +637,13 @@ app.delete('/api/users/:userId', requireAuth, requireAdmin, (req, res) => {
|
||||
}
|
||||
|
||||
sqlDb.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
|
||||
try {
|
||||
deleteSystemUser(user.username);
|
||||
} catch (sysErr) {
|
||||
console.error('Failed to delete system user:', sysErr.message);
|
||||
}
|
||||
|
||||
logActivity(req.user.id, 'delete', 'user', user.username);
|
||||
|
||||
return res.json({ message: 'User deleted successfully' });
|
||||
@@ -606,10 +659,11 @@ app.delete('/api/users/:userId', requireAuth, requireAdmin, (req, res) => {
|
||||
app.get('/api/files', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { parent_id, search } = req.query;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
// Search mode
|
||||
if (search) {
|
||||
const results = searchFiles(UPLOAD_DIR, search, '');
|
||||
const results = searchFiles(userDir, search, '', userDir);
|
||||
results.sort((a, b) => {
|
||||
if (a.is_folder !== b.is_folder) return a.is_folder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
@@ -621,15 +675,15 @@ app.get('/api/files', requireAuth, (req, res) => {
|
||||
let relDir = '';
|
||||
if (parent_id) {
|
||||
relDir = decodeFileId(parent_id);
|
||||
const safe = safeRelativePath(relDir);
|
||||
const safe = safeRelativePath(userDir, 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)) {
|
||||
const absDir = path.join(userDir, relDir);
|
||||
if (!absDir.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -645,7 +699,7 @@ app.get('/api/files', requireAuth, (req, res) => {
|
||||
const fullPath = path.join(absDir, entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
result.push(formatFileResponse(relativePath, stat, relDir || null));
|
||||
result.push(formatFileResponse(userDir, relativePath, stat, relDir || null));
|
||||
} catch (_e) {
|
||||
// skip files we cannot stat
|
||||
}
|
||||
@@ -667,20 +721,21 @@ app.get('/api/files', requireAuth, (req, res) => {
|
||||
app.get('/api/files/:fileId', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, 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));
|
||||
return res.json(formatFileResponse(userDir, safe, stat, parentRel === '.' ? null : parentRel));
|
||||
} catch (err) {
|
||||
console.error('Get file error:', err.message);
|
||||
return res.status(500).json({ detail: 'Internal server error' });
|
||||
@@ -690,6 +745,7 @@ app.get('/api/files/:fileId', requireAuth, (req, res) => {
|
||||
app.post('/api/files/folder', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { name, parent_id } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
if (!name) {
|
||||
return res.status(400).json({ detail: 'Folder name is required' });
|
||||
}
|
||||
@@ -702,22 +758,22 @@ app.post('/api/files/folder', requireAuth, (req, res) => {
|
||||
let parentRel = '';
|
||||
if (parent_id) {
|
||||
parentRel = decodeFileId(parent_id);
|
||||
const safe = safeRelativePath(parentRel);
|
||||
const safe = safeRelativePath(userDir, parentRel);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid parent path' });
|
||||
}
|
||||
parentRel = safe;
|
||||
const parentAbs = path.join(UPLOAD_DIR, parentRel);
|
||||
const parentAbs = path.join(userDir, 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);
|
||||
const newAbsPath = path.join(userDir, newRelPath);
|
||||
|
||||
// Security check
|
||||
if (!newAbsPath.startsWith(UPLOAD_DIR)) {
|
||||
if (!newAbsPath.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -743,37 +799,30 @@ app.post('/api/files/upload', requireAuth, upload.single('file'), (req, res) =>
|
||||
return res.status(400).json({ detail: 'No file provided' });
|
||||
}
|
||||
|
||||
const userDir = getUserDir(req.user.username);
|
||||
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);
|
||||
const safe = safeRelativePath(userDir, parentRel);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid parent path' });
|
||||
}
|
||||
parentRel = safe;
|
||||
const parentAbs = path.join(UPLOAD_DIR, parentRel);
|
||||
const parentAbs = path.join(userDir, 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 fileSize = req.file.size;
|
||||
const newRelPath = parentRel ? path.join(parentRel, fileName) : fileName;
|
||||
const newAbsPath = path.join(UPLOAD_DIR, newRelPath);
|
||||
const newAbsPath = path.join(userDir, newRelPath);
|
||||
|
||||
// Security check
|
||||
if (!newAbsPath.startsWith(UPLOAD_DIR)) {
|
||||
if (!newAbsPath.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -794,6 +843,7 @@ app.put('/api/files/:fileId/rename', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { new_name } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
if (!new_name) {
|
||||
return res.status(400).json({ detail: 'new_name is required' });
|
||||
}
|
||||
@@ -804,21 +854,21 @@ app.put('/api/files/:fileId/rename', requireAuth, (req, res) => {
|
||||
}
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, 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);
|
||||
const newAbsPath = path.join(userDir, newRelPath);
|
||||
|
||||
if (!newAbsPath.startsWith(UPLOAD_DIR)) {
|
||||
if (!newAbsPath.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -854,14 +904,15 @@ app.put('/api/files/:fileId/move', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { destination_id } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -869,12 +920,12 @@ app.put('/api/files/:fileId/move', requireAuth, (req, res) => {
|
||||
let destRel = '';
|
||||
if (destination_id) {
|
||||
destRel = decodeFileId(destination_id);
|
||||
const safeDest = safeRelativePath(destRel);
|
||||
const safeDest = safeRelativePath(userDir, destRel);
|
||||
if (safeDest === null) {
|
||||
return res.status(400).json({ detail: 'Invalid destination path' });
|
||||
}
|
||||
destRel = safeDest;
|
||||
const destAbs = path.join(UPLOAD_DIR, destRel);
|
||||
const destAbs = path.join(userDir, destRel);
|
||||
if (!fs.existsSync(destAbs) || !fs.statSync(destAbs).isDirectory()) {
|
||||
return res.status(404).json({ detail: 'Destination folder not found' });
|
||||
}
|
||||
@@ -891,9 +942,9 @@ app.put('/api/files/:fileId/move', requireAuth, (req, res) => {
|
||||
|
||||
const fileName = path.basename(safe);
|
||||
const newRelPath = destRel ? path.join(destRel, fileName) : fileName;
|
||||
const newAbsPath = path.join(UPLOAD_DIR, newRelPath);
|
||||
const newAbsPath = path.join(userDir, newRelPath);
|
||||
|
||||
if (!newAbsPath.startsWith(UPLOAD_DIR)) {
|
||||
if (!newAbsPath.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -923,14 +974,15 @@ app.post('/api/files/:fileId/copy', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const { destination_id } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -938,12 +990,12 @@ app.post('/api/files/:fileId/copy', requireAuth, (req, res) => {
|
||||
let destRel = '';
|
||||
if (destination_id) {
|
||||
destRel = decodeFileId(destination_id);
|
||||
const safeDest = safeRelativePath(destRel);
|
||||
const safeDest = safeRelativePath(userDir, destRel);
|
||||
if (safeDest === null) {
|
||||
return res.status(400).json({ detail: 'Invalid destination path' });
|
||||
}
|
||||
destRel = safeDest;
|
||||
const destAbs = path.join(UPLOAD_DIR, destRel);
|
||||
const destAbs = path.join(userDir, destRel);
|
||||
if (!fs.existsSync(destAbs) || !fs.statSync(destAbs).isDirectory()) {
|
||||
return res.status(404).json({ detail: 'Destination folder not found' });
|
||||
}
|
||||
@@ -951,18 +1003,11 @@ app.post('/api/files/:fileId/copy', requireAuth, (req, res) => {
|
||||
|
||||
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);
|
||||
const newAbsPath = path.join(userDir, newRelPath);
|
||||
|
||||
if (!newAbsPath.startsWith(UPLOAD_DIR)) {
|
||||
if (!newAbsPath.startsWith(userDir)) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
@@ -977,7 +1022,7 @@ app.post('/api/files/:fileId/copy', requireAuth, (req, res) => {
|
||||
while (fs.existsSync(finalAbsPath)) {
|
||||
const newName = `${base} (${counter})${ext}`;
|
||||
finalRelPath = parentDir ? path.join(parentDir, newName) : newName;
|
||||
finalAbsPath = path.join(UPLOAD_DIR, finalRelPath);
|
||||
finalAbsPath = path.join(userDir, finalRelPath);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
@@ -997,14 +1042,15 @@ app.post('/api/files/:fileId/copy', requireAuth, (req, res) => {
|
||||
app.delete('/api/files/:fileId', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -1030,6 +1076,7 @@ app.delete('/api/files/:fileId', requireAuth, (req, res) => {
|
||||
app.post('/api/files/bulk-delete', requireAuth, (req, res) => {
|
||||
try {
|
||||
const fileIds = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
if (!Array.isArray(fileIds)) {
|
||||
return res.status(400).json({ detail: 'Request body must be an array of file IDs' });
|
||||
}
|
||||
@@ -1037,10 +1084,10 @@ app.post('/api/files/bulk-delete', requireAuth, (req, res) => {
|
||||
let deleted = 0;
|
||||
for (const fileId of fileIds) {
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) continue;
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, 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 + '/%');
|
||||
@@ -1063,14 +1110,15 @@ app.post('/api/files/bulk-delete', requireAuth, (req, res) => {
|
||||
app.get('/api/files/:fileId/download', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -1098,14 +1146,15 @@ app.get('/api/files/:fileId/download', requireAuth, (req, res) => {
|
||||
app.get('/api/files/:fileId/preview', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -1134,9 +1183,10 @@ app.get('/api/files/:fileId/preview', requireAuth, (req, res) => {
|
||||
app.get('/api/files/:fileId/breadcrumb', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { fileId } = req.params;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
|
||||
const relPath = decodeFileId(fileId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid path' });
|
||||
}
|
||||
@@ -1167,18 +1217,19 @@ app.get('/api/files/:fileId/breadcrumb', requireAuth, (req, res) => {
|
||||
app.post('/api/shares', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { file_id, expires_in_hours, password } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
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);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: 'Invalid file path' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -1216,10 +1267,11 @@ app.post('/api/shares', requireAuth, (req, res) => {
|
||||
app.get('/api/shares', requireAuth, (req, res) => {
|
||||
try {
|
||||
const shares = sqlDb.prepare('SELECT * FROM shares WHERE owner_id = ?').all(req.user.id);
|
||||
const userDir = getUserDir(req.user.username);
|
||||
const result = [];
|
||||
|
||||
for (const share of shares) {
|
||||
const absPath = path.join(UPLOAD_DIR, share.file_path);
|
||||
const absPath = path.join(userDir, share.file_path);
|
||||
if (fs.existsSync(absPath)) {
|
||||
const fileName = path.basename(share.file_path);
|
||||
result.push({
|
||||
@@ -1270,7 +1322,13 @@ app.get('/api/share/:token', (req, res) => {
|
||||
return res.status(410).json({ detail: 'Share link has expired' });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, share.file_path);
|
||||
const owner = sqlDb.prepare('SELECT username FROM users WHERE id = ?').get(share.owner_id);
|
||||
if (!owner) {
|
||||
return res.status(404).json({ detail: 'Share owner not found' });
|
||||
}
|
||||
|
||||
const ownerDir = getUserDir(owner.username);
|
||||
const absPath = path.join(ownerDir, share.file_path);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found' });
|
||||
}
|
||||
@@ -1312,8 +1370,14 @@ app.post('/api/share/:token/download', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, share.file_path);
|
||||
if (!absPath.startsWith(UPLOAD_DIR) || !fs.existsSync(absPath)) {
|
||||
const owner = sqlDb.prepare('SELECT username FROM users WHERE id = ?').get(share.owner_id);
|
||||
if (!owner) {
|
||||
return res.status(404).json({ detail: 'Share owner not found' });
|
||||
}
|
||||
|
||||
const ownerDir = getUserDir(owner.username);
|
||||
const absPath = path.join(ownerDir, share.file_path);
|
||||
if (!absPath.startsWith(ownerDir) || !fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: 'File not found on storage' });
|
||||
}
|
||||
|
||||
@@ -1375,10 +1439,10 @@ app.get('/api/activity', requireAuth, (req, res) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/stats', requireAuth, (req, res) => {
|
||||
try {
|
||||
const storageUsed = getUserStorageUsed(req.user.id);
|
||||
const storageQuota = req.user.storage_quota || 10737418240;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
const storageUsed = getUserStorageUsed(req.user.username);
|
||||
|
||||
const counts = countFilesAndFolders(UPLOAD_DIR);
|
||||
const counts = countFilesAndFolders(userDir);
|
||||
const totalFiles = counts.files;
|
||||
const totalFolders = counts.folders;
|
||||
|
||||
@@ -1386,12 +1450,10 @@ app.get('/api/stats', requireAuth, (req, res) => {
|
||||
'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);
|
||||
const typeBreakdown = getTypeBreakdown(userDir);
|
||||
|
||||
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,
|
||||
@@ -1409,6 +1471,7 @@ app.get('/api/stats', requireAuth, (req, res) => {
|
||||
app.post('/api/download-zip', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
const userDir = getUserDir(req.user.username);
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ detail: 'items array is required and must not be empty' });
|
||||
}
|
||||
@@ -1417,12 +1480,12 @@ app.post('/api/download-zip', requireAuth, async (req, res) => {
|
||||
const fileRecords = [];
|
||||
for (const itemId of items) {
|
||||
const relPath = decodeFileId(itemId);
|
||||
const safe = safeRelativePath(relPath);
|
||||
const safe = safeRelativePath(userDir, relPath);
|
||||
if (safe === null) {
|
||||
return res.status(400).json({ detail: `Invalid path for item: ${itemId}` });
|
||||
}
|
||||
|
||||
const absPath = path.join(UPLOAD_DIR, safe);
|
||||
const absPath = path.join(userDir, safe);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ detail: `Item not found: ${itemId}` });
|
||||
}
|
||||
@@ -1593,6 +1656,7 @@ function seedAdmin() {
|
||||
'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}`);
|
||||
createSystemUser(ADMIN_USERNAME, ADMIN_PASSWORD);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1601,8 +1665,8 @@ function seedAdmin() {
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
try {
|
||||
// Ensure upload directory exists
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// Ensure home base directory exists
|
||||
fs.mkdirSync(HOME_BASE, { recursive: true });
|
||||
|
||||
// Initialize SQLite database
|
||||
initDatabase();
|
||||
@@ -1611,6 +1675,15 @@ function main() {
|
||||
// Seed admin
|
||||
seedAdmin();
|
||||
|
||||
// Ensure existing users have home directories (migration support)
|
||||
const existingUsers = sqlDb.prepare('SELECT username FROM users').all();
|
||||
for (const u of existingUsers) {
|
||||
const homeDir = path.join(HOME_BASE, u.username);
|
||||
if (!fs.existsSync(homeDir)) {
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure tmp directory exists
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user