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:
2026-04-26 05:51:23 +00:00
parent 888acdbd7e
commit b1697d6e1e
17 changed files with 216 additions and 221 deletions

View File

@@ -8,7 +8,7 @@
"dynamic_config": true,
"no_gui": false,
"tipi_version": 2,
"version": "1.0.2",
"version": "2.0.0",
"categories": ["network", "utilities"],
"description": "All-in-one NAS container with Samba SMB file sharing and CloudNAS web file manager. Browse files via web UI or connect via SMB at \\\\192.168.0.15\\data with username alexz. No database required — uses filesystem + SQLite.",
"short_desc": "NAS with SMB + web file manager",

View File

@@ -4,7 +4,7 @@
"services": [
{
"name": "nas-samba",
"image": "git.alexzaw.dev/alexz/nas-samba:1.0.2",
"image": "git.alexzaw.dev/alexz/nas-samba:2.0.0",
"isMain": true,
"internalPort": 8080,
"hostname": "nas.alexzaw.dev",

View File

@@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.f6816838.css",
"main.js": "/static/js/main.2e7681ef.js",
"main.css": "/static/css/main.6f136b27.css",
"main.js": "/static/js/main.f1998df4.js",
"index.html": "/index.html",
"main.f6816838.css.map": "/static/css/main.f6816838.css.map",
"main.2e7681ef.js.map": "/static/js/main.2e7681ef.js.map"
"main.6f136b27.css.map": "/static/css/main.6f136b27.css.map",
"main.f1998df4.js.map": "/static/js/main.f1998df4.js.map"
},
"entrypoints": [
"static/css/main.f6816838.css",
"static/js/main.2e7681ef.js"
"static/css/main.6f136b27.css",
"static/js/main.f1998df4.js"
]
}

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#0F172A"/><meta name="description" content="CloudSync - Enterprise File Manager"/><title>CloudSync - Enterprise File Manager</title><script defer="defer" src="/static/js/main.2e7681ef.js"></script><link href="/static/css/main.f6816838.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#0F172A"/><meta name="description" content="CloudSync - Enterprise File Manager"/><title>CloudSync - Enterprise File Manager</title><script defer="defer" src="/static/js/main.f1998df4.js"></script><link href="/static/css/main.6f136b27.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@ import {
MoreVertical, Download, Trash2, Edit3, Move, Copy,
Share2, ChevronRight, Home, Moon, Sun, LogOut, Users,
Activity, X, Upload, Folder, File, Image,
Film, Music, Archive, Code, FileText, HardDrive,
Film, Music, Archive, Code, FileText,
Link, Shield
} from 'lucide-react';
import { formatBytes, formatDate, cn } from '../lib/utils';
@@ -159,42 +159,9 @@ function Modal({ open, onClose, title, children, size = 'md' }) {
);
}
// Storage Usage Component
function StorageUsage({ used, quota }) {
const percentage = quota > 0 ? (used / quota) * 100 : 0;
const isWarning = percentage > 80;
const isCritical = percentage > 95;
return (
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Storage</span>
</div>
<span className="text-sm text-muted-foreground">
{formatBytes(used)} / {formatBytes(quota)}
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-300",
isCritical ? "bg-destructive" : isWarning ? "bg-amber-500" : "bg-primary"
)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-2">
{percentage.toFixed(1)}% used
</p>
</div>
);
}
// Main Dashboard Component
function Dashboard() {
const { user, logout, updateUser } = useAuth();
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
// State
@@ -232,7 +199,7 @@ function Dashboard() {
const [loadingUsers, setLoadingUsers] = useState(false);
const [showUserForm, setShowUserForm] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [userForm, setUserForm] = useState({ username: '', password: '', name: '', role: 'user', storage_quota: 10737418240 });
const [userForm, setUserForm] = useState({ username: '', password: '', name: '', role: 'user' });
// Activity state
const [activities, setActivities] = useState([]);
@@ -274,11 +241,10 @@ function Dashboard() {
try {
const response = await api.getStats();
setStats(response.data);
updateUser({ storage_used: response.data.storage_used });
} catch (error) {
console.error('Failed to load stats');
}
}, [updateUser]);
}, []);
useEffect(() => {
loadFiles();
@@ -467,7 +433,7 @@ function Dashboard() {
}
try {
if (editingUser) {
const updateData = { name: userForm.name, role: userForm.role, storage_quota: userForm.storage_quota };
const updateData = { name: userForm.name, role: userForm.role };
if (userForm.password) updateData.password = userForm.password;
await api.updateUser(editingUser.id, updateData);
toast.success('User updated');
@@ -477,7 +443,7 @@ function Dashboard() {
}
setShowUserForm(false);
setEditingUser(null);
setUserForm({ username: '', password: '', name: '', role: 'user', storage_quota: 10737418240 });
setUserForm({ username: '', password: '', name: '', role: 'user' });
loadUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save user');
@@ -661,12 +627,7 @@ function Dashboard() {
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{/* Storage & Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StorageUsage
used={stats?.storage_used || user?.storage_used || 0}
quota={stats?.storage_quota || user?.storage_quota || 10737418240}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<File className="w-4 h-4 text-muted-foreground" />
@@ -1212,24 +1173,9 @@ function Dashboard() {
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Storage Quota</label>
<select
value={userForm.storage_quota}
onChange={(e) => setUserForm(prev => ({ ...prev, storage_quota: Number(e.target.value) }))}
data-testid="user-quota-select"
className="w-full h-10 px-4 bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value={1073741824}>1 GB</option>
<option value={5368709120}>5 GB</option>
<option value={10737418240}>10 GB</option>
<option value={53687091200}>50 GB</option>
<option value={107374182400}>100 GB</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => { setShowUserForm(false); setEditingUser(null); setUserForm({ username: '', password: '', name: '', role: 'user', storage_quota: 10737418240 }); }}
onClick={() => { setShowUserForm(false); setEditingUser(null); setUserForm({ username: '', password: '', name: '', role: 'user' }); }}
className="flex-1 h-10 bg-secondary text-secondary-foreground rounded-lg hover:opacity-80"
>
Cancel
@@ -1279,10 +1225,10 @@ function Dashboard() {
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{formatBytes(u.storage_used)} / {formatBytes(u.storage_quota)}
{u.role}
</span>
<button
onClick={() => { setEditingUser(u); setUserForm({ username: u.username, password: '', name: u.name, role: u.role, storage_quota: u.storage_quota }); setShowUserForm(true); }}
onClick={() => { setEditingUser(u); setUserForm({ username: u.username, password: '', name: u.name, role: u.role }); setShowUserForm(true); }}
className="p-2 hover:bg-accent rounded-lg"
data-testid={`edit-user-${u.id}`}
>

View File

@@ -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 });

View File

@@ -1,33 +1,8 @@
#!/bin/bash
set -e
# Create users group if it doesn't exist
getent group users >/dev/null 2>&1 || groupadd -g 100 users
# Create alexz user with UID 1000 if not exists
if ! id -u alexz >/dev/null 2>&1; then
# Remove any existing user with UID 1000 (e.g. ubuntu)
existing_user=$(getent passwd 1000 | cut -d: -f1)
if [ -n "$existing_user" ] && [ "$existing_user" != "alexz" ]; then
userdel "$existing_user" 2>/dev/null || true
fi
useradd -u 1000 -g users -M -s /usr/sbin/nologin alexz
echo "Created user alexz (UID 1000)"
fi
# Set Samba password from environment variable
if [ -n "$SMB_PASSWORD" ]; then
echo -e "${SMB_PASSWORD}\n${SMB_PASSWORD}" | smbpasswd -a -s alexz
echo "Samba password set for alexz"
else
echo "WARNING: SMB_PASSWORD not set. Samba authentication will fail."
fi
# Ensure directories exist
mkdir -p /config /var/log/samba /run/samba /nas
# Ensure correct ownership
chown alexz:users /nas 2>/dev/null || true
mkdir -p /config /var/log/samba /run/samba /nas/home
echo "Starting supervisord..."
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

View File

@@ -9,10 +9,11 @@
server min protocol = SMB2
server max protocol = SMB3
[data]
path = /nas
valid users = alexz
browsable = yes
writable = yes
[homes]
comment = Home Directory
browseable = no
read only = no
valid users = %S
path = /nas/home/%S
create mask = 0664
directory mask = 0775

View File

@@ -27,7 +27,7 @@ directory=/app
autostart=true
autorestart=true
priority=20
environment=PORT="8080",UPLOAD_DIR="/nas",DB_PATH="/config/cloudsync.db",JWT_SECRET="%(ENV_JWT_SECRET)s",SMB_PASSWORD="%(ENV_SMB_PASSWORD)s"
environment=PORT="8080",DB_PATH="/config/cloudsync.db",JWT_SECRET="%(ENV_JWT_SECRET)s",SMB_PASSWORD="%(ENV_SMB_PASSWORD)s"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr