nas-samba: rebuilt as single Ubuntu image with Samba + CloudNAS (no MongoDB)

This commit is contained in:
2026-03-16 23:22:42 +00:00
parent 0104827234
commit 618e023996
43 changed files with 26653 additions and 107 deletions

View File

@@ -0,0 +1 @@
/* Additional app-specific styles */

View File

@@ -0,0 +1,51 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Toaster, toast } from 'sonner';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ThemeProvider, useTheme } from './context/ThemeContext';
import LoginPage from './components/LoginPage';
import Dashboard from './components/Dashboard';
import SharePage from './components/SharePage';
import LoadingScreen from './components/ui/LoadingScreen';
function AppContent() {
const { user, loading } = useAuth();
// Handle /share/:token routes without React Router
const path = window.location.pathname;
const shareMatch = path.match(/^\/share\/([a-f0-9]+)$/);
if (shareMatch) {
return <SharePage token={shareMatch[1]} />;
}
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return <LoginPage />;
}
return <Dashboard />;
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Toaster
position="top-right"
richColors
closeButton
toastOptions={{
style: {
fontFamily: 'Inter, system-ui, sans-serif',
},
}}
/>
<AppContent />
</AuthProvider>
</ThemeProvider>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { toast } from 'sonner';
import { Cloud, Eye, EyeOff, Loader2 } from 'lucide-react';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
toast.error('Please enter username and password');
return;
}
setLoading(true);
try {
const user = await login(username, password);
toast.success(`Welcome back, ${user.name}!`);
} catch (error) {
const message = error.response?.data?.detail || 'Invalid credentials';
toast.error(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex">
{/* Left side - Background image */}
<div
className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative bg-cover bg-center"
style={{
backgroundImage: 'url(https://images.pexels.com/photos/28428586/pexels-photo-28428586.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940)'
}}
>
<div className="absolute inset-0 bg-black/40"></div>
<div className="relative z-10 flex flex-col justify-end p-12 text-white">
<h1 className="font-secondary text-4xl font-bold mb-4">CloudSync</h1>
<p className="text-lg text-white/80 max-w-md">
Enterprise-grade file management with secure sharing, storage quotas, and comprehensive activity tracking.
</p>
</div>
</div>
{/* Right side - Login form */}
<div className="flex-1 flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md">
{/* Logo for mobile */}
<div className="lg:hidden flex items-center gap-3 mb-8">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
<Cloud className="w-5 h-5 text-primary-foreground" />
</div>
<span className="font-secondary text-2xl font-bold text-foreground">CloudSync</span>
</div>
<div className="mb-8">
<h2 className="font-secondary text-3xl font-bold text-foreground mb-2">Sign in</h2>
<p className="text-muted-foreground">Enter your credentials to access your files</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
disabled={loading}
data-testid="login-username-input"
className="w-full h-11 px-4 rounded-lg border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all disabled:opacity-50"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
disabled={loading}
data-testid="login-password-input"
className="w-full h-11 px-4 pr-12 rounded-lg border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all disabled:opacity-50"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
data-testid="toggle-password-visibility"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
data-testid="login-submit-btn"
className="w-full h-11 bg-primary text-primary-foreground font-medium rounded-lg hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</button>
</form>
<div className="mt-8 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Demo credentials:</span>
<br />
Username: <code className="font-mono bg-background px-1 rounded">admin</code>
<br />
Password: <code className="font-mono bg-background px-1 rounded">admin123</code>
</p>
</div>
</div>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,182 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Cloud, CheckCircle, Lock, FileText, AlertCircle } from 'lucide-react';
import { formatBytes } from '../lib/utils';
import * as api from '../lib/api';
export default function SharePage({ token }) {
const [fileInfo, setFileInfo] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [password, setPassword] = useState('');
const [downloaded, setDownloaded] = useState(false);
const [downloading, setDownloading] = useState(false);
const doDownload = useCallback(async (pwd) => {
if (!fileInfo || downloading) return;
setDownloading(true);
setError(null);
try {
const url = api.downloadSharedFile(token, pwd || null);
const res = await fetch(url, { method: 'POST' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Download failed');
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileInfo.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
setDownloaded(true);
} catch (err) {
setError(err.message);
setDownloading(false);
}
}, [fileInfo, downloading, token]);
useEffect(() => {
async function load() {
try {
const res = await api.getSharedFileInfo(token);
setFileInfo(res.data);
} catch (err) {
const status = err.response?.status;
if (status === 410) {
setError('This share link has expired.');
} else if (status === 404) {
setError('Share link not found.');
} else {
setError('Failed to load shared file.');
}
} finally {
setLoading(false);
}
}
load();
}, [token]);
// Auto-download if no password required
useEffect(() => {
if (fileInfo && !fileInfo.requires_password && !downloaded && !downloading) {
doDownload(null);
}
}, [fileInfo, downloaded, downloading, doDownload]);
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
);
}
if (error && !fileInfo) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Unavailable</h2>
<p className="text-muted-foreground">{error}</p>
</div>
</div>
);
}
// Downloaded state
if (downloaded) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
<div className="flex items-center justify-center gap-2 mb-6">
<Cloud className="w-6 h-6 text-primary" />
<span className="text-lg font-semibold">CloudSync</span>
</div>
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">File Downloaded</h2>
<div className="flex items-center gap-3 justify-center p-3 bg-accent/50 rounded-lg mt-4">
<FileText className="w-6 h-6 text-muted-foreground flex-shrink-0" />
<div className="text-left min-w-0">
<p className="font-medium text-sm truncate">{fileInfo.name}</p>
<p className="text-xs text-muted-foreground">{formatBytes(fileInfo.size)}</p>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4">This link has been used and is no longer active.</p>
</div>
</div>
);
}
// Password required state
if (fileInfo.requires_password && !downloaded) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full">
<div className="flex items-center gap-2 mb-6">
<Cloud className="w-6 h-6 text-primary" />
<span className="text-lg font-semibold">CloudSync</span>
</div>
<div className="flex items-center gap-4 mb-6 p-4 bg-accent/50 rounded-lg">
<FileText className="w-10 h-10 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium truncate">{fileInfo.name}</p>
<p className="text-sm text-muted-foreground">{formatBytes(fileInfo.size)}</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-destructive/10 text-destructive text-sm rounded-lg">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
<Lock className="w-4 h-4 inline mr-1" />
This file is password protected
</label>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && password) doDownload(password); }}
placeholder="Enter password"
className="w-full h-10 px-4 bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
</div>
<button
onClick={() => doDownload(password)}
disabled={downloading || !password}
className="w-full h-10 bg-primary text-primary-foreground rounded-lg hover:opacity-90 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{downloading ? (
<div className="animate-spin w-4 h-4 border-2 border-primary-foreground border-t-transparent rounded-full"></div>
) : (
'Download'
)}
</button>
</div>
</div>
);
}
// Downloading state (auto-download in progress, no password)
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
<div className="flex items-center justify-center gap-2 mb-6">
<Cloud className="w-6 h-6 text-primary" />
<span className="text-lg font-semibold">CloudSync</span>
</div>
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-muted-foreground">Downloading {fileInfo.name}...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { cn } from '../../lib/utils';
export function LoadingScreen() {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="relative w-12 h-12">
<div className="absolute inset-0 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-muted-foreground font-medium">Loading CloudSync...</p>
</div>
</div>
);
}
export default LoadingScreen;

View File

@@ -0,0 +1,69 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { login as apiLogin, getMe } from '../lib/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(localStorage.getItem('token'));
const checkAuth = useCallback(async () => {
const storedToken = localStorage.getItem('token');
if (!storedToken) {
setLoading(false);
return;
}
try {
const response = await getMe();
setUser(response.data);
setToken(storedToken);
} catch (error) {
localStorage.removeItem('token');
setUser(null);
setToken(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
const login = async (username, password) => {
const response = await apiLogin(username, password);
const { token: newToken, user: userData } = response.data;
localStorage.setItem('token', newToken);
setToken(newToken);
setUser(userData);
return userData;
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
const updateUser = useCallback((userData) => {
setUser(prev => ({ ...prev, ...userData }));
}, []);
return (
<AuthContext.Provider value={{ user, token, loading, login, logout, updateUser, checkAuth }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,116 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #FFFFFF;
--foreground: #0A0A0A;
--card: #FFFFFF;
--card-foreground: #0A0A0A;
--popover: #FFFFFF;
--popover-foreground: #0A0A0A;
--primary: #0F172A;
--primary-foreground: #F8FAFC;
--secondary: #F1F5F9;
--secondary-foreground: #0F172A;
--muted: #F1F5F9;
--muted-foreground: #64748B;
--accent: #F1F5F9;
--accent-foreground: #0F172A;
--destructive: #EF4444;
--destructive-foreground: #F8FAFC;
--border: #E2E8F0;
--input: #E2E8F0;
--ring: #0F172A;
--radius: 0.5rem;
}
.dark {
--background: #020617;
--foreground: #F8FAFC;
--card: #0F172A;
--card-foreground: #F8FAFC;
--popover: #0F172A;
--popover-foreground: #F8FAFC;
--primary: #F8FAFC;
--primary-foreground: #0F172A;
--secondary: #1E293B;
--secondary-foreground: #F8FAFC;
--muted: #1E293B;
--muted-foreground: #94A3B8;
--accent: #1E293B;
--accent-foreground: #F8FAFC;
--destructive: #7F1D1D;
--destructive-foreground: #F8FAFC;
--border: #1E293B;
--input: #1E293B;
--ring: #D1D5DB;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--muted);
}
::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--foreground);
}
/* Skeleton loader animation */
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton {
animation: skeleton-pulse 1.5s ease-in-out infinite;
background: linear-gradient(90deg, var(--muted) 25%, var(--border) 50%, var(--muted) 75%);
background-size: 200% 100%;
}
/* File drop zone styles */
.drop-zone-active {
border-color: var(--ring) !important;
background-color: var(--accent) !important;
}
/* Focus visible styles */
.focus-visible:focus {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Transition utilities */
.transition-default {
transition: all 200ms ease-in-out;
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,136 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_BACKEND_URL || '';
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
}
return Promise.reject(error);
}
);
// Auth
export const login = (username, password) =>
api.post('/api/auth/login', { username, password });
export const getMe = () =>
api.get('/api/auth/me');
// Users
export const getUsers = () =>
api.get('/api/users');
export const createUser = (userData) =>
api.post('/api/users', userData);
export const updateUser = (userId, userData) =>
api.put(`/api/users/${userId}`, userData);
export const deleteUser = (userId) =>
api.delete(`/api/users/${userId}`);
// Files
export const getFiles = (parentId = null, search = null) => {
const params = new URLSearchParams();
if (parentId) params.append('parent_id', parentId);
if (search) params.append('search', search);
return api.get(`/api/files?${params.toString()}`);
};
export const getFile = (fileId) =>
api.get(`/api/files/${fileId}`);
export const createFolder = (name, parentId = null) =>
api.post('/api/files/folder', { name, parent_id: parentId, is_folder: true });
export const uploadFile = (file, parentId, onProgress) => {
const formData = new FormData();
formData.append('file', file);
if (parentId) {
formData.append('parent_id', parentId);
}
return api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (onProgress) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(progress);
}
},
});
};
export const renameFile = (fileId, newName) =>
api.put(`/api/files/${fileId}/rename`, { new_name: newName });
export const moveFile = (fileId, destinationId) =>
api.put(`/api/files/${fileId}/move`, { destination_id: destinationId });
export const copyFile = (fileId, destinationId) =>
api.post(`/api/files/${fileId}/copy`, { destination_id: destinationId });
export const deleteFile = (fileId) =>
api.delete(`/api/files/${fileId}`);
export const bulkDeleteFiles = (fileIds) =>
api.post('/api/files/bulk-delete', fileIds);
export const getDownloadUrl = (fileId) =>
`${API_URL}/api/files/${fileId}/download`;
export const getPreviewUrl = (fileId) =>
`${API_URL}/api/files/${fileId}/preview`;
export const getBreadcrumb = (fileId) =>
api.get(`/api/files/${fileId}/breadcrumb`);
// Shares
export const getShares = () =>
api.get('/api/shares');
export const createShare = (fileId, expiresInHours = 24, password = null) =>
api.post('/api/shares', { file_id: fileId, expires_in_hours: expiresInHours, password });
export const deleteShare = (shareId) =>
api.delete(`/api/shares/${shareId}`);
export const getSharedFileInfo = (token) =>
api.get(`/api/share/${token}`);
export const downloadSharedFile = (token, password = null) => {
const params = password ? `?password=${encodeURIComponent(password)}` : '';
return `${API_URL}/api/share/${token}/download${params}`;
};
// Activity
export const getActivity = (limit = 50) =>
api.get(`/api/activity?limit=${limit}`);
// Stats
export const getStats = () =>
api.get('/api/stats');
export default api;

View File

@@ -0,0 +1,33 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes, decimals = 1) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export function getFileExtension(filename) {
return filename.includes('.') ? filename.split('.').pop().toLowerCase() : '';
}