feat: explorador de archivos local y autenticacion/conexion a unidad de red UNC en storage root

This commit is contained in:
Carlos Tello
2026-08-17 00:05:15 -03:00
parent 7badc34816
commit f3b6d8ef71
5 changed files with 457 additions and 25 deletions
+110
View File
@@ -1,5 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
import os
import string
import uuid
from pathlib import Path
from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.api.deps import require_admin from app.api.deps import require_admin
from app.models.models import User from app.models.models import User
@@ -8,6 +15,11 @@ from app.services.settings_service import get_all_settings, update_settings_serv
router = APIRouter(prefix="/settings", tags=["System Settings"]) router = APIRouter(prefix="/settings", tags=["System Settings"])
class TestShareRequest(BaseModel):
path: str
username: str
password: str
@router.get("", response_model=SystemSettingsResponse) @router.get("", response_model=SystemSettingsResponse)
async def get_settings( async def get_settings(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -22,6 +34,13 @@ async def get_settings(
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)), "default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)), "default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)), "default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
"auth_mode": settings_dict.get("auth_mode", "local"),
"radius_host": settings_dict.get("radius_host", ""),
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
"radius_secret": settings_dict.get("radius_secret", ""),
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
"storage_network_user": settings_dict.get("storage_network_user", ""),
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
} }
@router.put("", response_model=SystemSettingsResponse) @router.put("", response_model=SystemSettingsResponse)
@@ -49,4 +68,95 @@ async def update_settings(
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)), "default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)), "default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)), "default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
"auth_mode": settings_dict.get("auth_mode", "local"),
"radius_host": settings_dict.get("radius_host", ""),
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
"radius_secret": settings_dict.get("radius_secret", ""),
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
"storage_network_user": settings_dict.get("storage_network_user", ""),
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
} }
@router.get("/explore-dir")
async def explore_directory(
path: Optional[str] = None,
admin_user: User = Depends(require_admin)
):
"""
Explore directories on the host filesystem.
"""
import platform
if not path or path.strip() == "":
if platform.system().lower() == "windows":
drives = []
for letter in string.ascii_uppercase:
drive_path = f"{letter}:\\"
if os.path.exists(drive_path):
drives.append({"name": drive_path, "path": drive_path})
return {"current_path": "", "folders": drives}
else:
return {"current_path": "/", "folders": [{"name": "/", "path": "/"}]}
# Resolve path
target = Path(path).resolve()
if not target.exists() or not target.is_dir():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"The path '{path}' does not exist or is not a directory."
)
try:
folders = []
for entry in os.scandir(target):
try:
if entry.is_dir():
folders.append({
"name": entry.name,
"path": str(Path(entry.path).resolve())
})
except Exception:
continue # Skip inaccessible
folders.sort(key=lambda x: x["name"].lower())
return {
"current_path": str(target),
"parent_path": str(target.parent) if target.parent != target else None,
"folders": folders
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to read directory contents: {str(e)}"
)
@router.post("/test-network-share")
async def test_network_share(
payload: TestShareRequest,
admin_user: User = Depends(require_admin)
):
"""
Attempts to connect to a UNC network share and tests write access.
"""
from app.services.settings_service import connect_network_share
ok = connect_network_share(payload.path, payload.username, payload.password)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to connect to the network share. Check the server path, username, and password."
)
try:
target_path = Path(payload.path)
os.makedirs(target_path, exist_ok=True)
test_file = target_path / f".write_test_{uuid.uuid4().hex[:6]}"
test_file.touch()
test_file.unlink()
return {"message": "Network share connected and write access verified successfully!"}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Network share connected, but write access test failed: {str(e)}"
)
+14
View File
@@ -256,6 +256,13 @@ class SystemSettingsResponse(BaseModel):
default_keep_daily: int default_keep_daily: int
default_keep_weekly: int default_keep_weekly: int
default_keep_monthly: int default_keep_monthly: int
auth_mode: Optional[str] = "local"
radius_host: Optional[str] = ""
radius_port: Optional[int] = 1812
radius_secret: Optional[str] = ""
storage_network_enabled: Optional[bool] = False
storage_network_user: Optional[str] = ""
storage_network_pass: Optional[str] = ""
class SystemSettingsUpdate(BaseModel): class SystemSettingsUpdate(BaseModel):
storage_root: Optional[str] = None storage_root: Optional[str] = None
@@ -264,6 +271,13 @@ class SystemSettingsUpdate(BaseModel):
default_keep_daily: Optional[int] = None default_keep_daily: Optional[int] = None
default_keep_weekly: Optional[int] = None default_keep_weekly: Optional[int] = None
default_keep_monthly: Optional[int] = None default_keep_monthly: Optional[int] = None
auth_mode: Optional[str] = None
radius_host: Optional[str] = None
radius_port: Optional[int] = None
radius_secret: Optional[str] = None
storage_network_enabled: Optional[bool] = None
storage_network_user: Optional[str] = None
storage_network_pass: Optional[str] = None
class AgentJobRegister(BaseModel): class AgentJobRegister(BaseModel):
name: str name: str
+60 -15
View File
@@ -16,8 +16,40 @@ DEFAULT_SETTINGS = {
"radius_host": "", "radius_host": "",
"radius_port": "1812", "radius_port": "1812",
"radius_secret": "", "radius_secret": "",
"storage_network_enabled": "false",
"storage_network_user": "",
"storage_network_pass": "",
} }
def connect_network_share(path: str, username: str, password: str) -> bool:
"""
Mounts a UNC network share on Windows using 'net use'.
"""
if not (path.startswith(r"\\") or path.startswith("//")):
return True
parts = [p for p in path.replace("/", "\\").split("\\") if p]
if len(parts) < 2:
return False
unc_base = f"\\\\{parts[0]}\\{parts[1]}"
import subprocess
print(f"Connecting to network share {unc_base} with user '{username}'...")
# 1. Remove existing connection if any (ignore errors)
subprocess.run(["net", "use", unc_base, "/delete", "/y"], capture_output=True)
# 2. Add connection
cmd = ["net", "use", unc_base]
if password:
cmd.append(password)
if username:
cmd.append(f"/user:{username}")
cmd.append("/persistent:yes")
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
async def get_setting(db: AsyncSession, key: str) -> str: async def get_setting(db: AsyncSession, key: str) -> str:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
@@ -43,23 +75,36 @@ async def get_all_settings(db: AsyncSession) -> dict:
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict: async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
from app.storage.local import storage_provider from app.storage.local import storage_provider
current = await get_all_settings(db)
merged = {**current, **new_settings}
# Connect network share if enabled and it's a UNC path
net_enabled = str(merged.get("storage_network_enabled", "false")).lower() == "true"
net_user = merged.get("storage_network_user", "")
net_pass = merged.get("storage_network_pass", "")
root_path = merged.get("storage_root", "")
if net_enabled and root_path and (root_path.startswith(r"\\") or root_path.startswith("//")):
ok = connect_network_share(root_path, net_user, net_pass)
if not ok:
raise ValueError("Failed to authenticate or connect to the specified network share (UNC). Verify path and credentials.")
# Perform standard storage root validation
if "storage_root" in new_settings:
v = new_settings["storage_root"]
path = Path(v).resolve()
try:
os.makedirs(path, exist_ok=True)
test_file = path / ".write_test"
test_file.touch()
test_file.unlink()
except Exception as e:
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
storage_provider.root_dir = path
for k, v in new_settings.items(): for k, v in new_settings.items():
if k in DEFAULT_SETTINGS and v is not None: if k in DEFAULT_SETTINGS and v is not None:
# If changing storage root, validate and apply
if k == "storage_root":
path = Path(v).resolve()
try:
os.makedirs(path, exist_ok=True)
# Test write access
test_file = path / ".write_test"
test_file.touch()
test_file.unlink()
except Exception as e:
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
# Apply to in-memory storage provider
storage_provider.root_dir = path
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k)) result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting: if setting:
+261 -10
View File
@@ -6,7 +6,13 @@ import {
Calendar, Calendar,
Loader2, Loader2,
ShieldCheck, ShieldCheck,
AlertCircle AlertCircle,
FolderOpen,
ChevronRight,
ArrowUp,
Globe,
RefreshCw,
X
} from 'lucide-react'; } from 'lucide-react';
import { api, SystemSettingsResponse } from '../services/api'; import { api, SystemSettingsResponse } from '../services/api';
@@ -38,9 +44,58 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
auth_mode: 'local', auth_mode: 'local',
radius_host: '', radius_host: '',
radius_port: 1812, radius_port: 1812,
radius_secret: '' radius_secret: '',
storage_network_enabled: false,
storage_network_user: '',
storage_network_pass: ''
}); });
const [showExplorer, setShowExplorer] = useState(false);
const [explorerPath, setExplorerPath] = useState('');
const [explorerFolders, setExplorerFolders] = useState<Array<{ name: string; path: string }>>([]);
const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null);
const [explorerLoading, setExplorerLoading] = useState(false);
const [explorerError, setExplorerError] = useState<string | null>(null);
const [testingShare, setTestingShare] = useState(false);
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
const loadExplorerPath = async (path?: string) => {
setExplorerLoading(true);
setExplorerError(null);
try {
const data = await api.exploreDir(path);
setExplorerPath(data.current_path);
setExplorerParentPath(data.parent_path);
setExplorerFolders(data.folders);
} catch (err: any) {
setExplorerError(`Error al leer directorio: ${err.message}`);
} finally {
setExplorerLoading(false);
}
};
useEffect(() => {
if (showExplorer) {
loadExplorerPath(explorerPath || formData.storage_root);
}
}, [showExplorer]);
const handleTestNetworkShare = async () => {
setTestingShare(true);
try {
const res = await api.testNetworkShare({
path: formData.storage_root,
username: formData.storage_network_user,
password: formData.storage_network_pass,
});
alert(`✓ Éxito: ${res.message}`);
} catch (err: any) {
alert(`✗ Error de conexión: ${err.message}`);
} finally {
setTestingShare(false);
}
};
const fetchSettings = async () => { const fetchSettings = async () => {
setLoading(true); setLoading(true);
setErrorMsg(null); setErrorMsg(null);
@@ -117,18 +172,94 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
</div> </div>
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label> <label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
<input <div style={{ display: 'flex', gap: '10px' }}>
type="text" <input
className="form-input" type="text"
required className="form-input"
placeholder="Ej: C:\backups o /mnt/nas/backups" required
value={formData.storage_root} style={{ flex: 1 }}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })} placeholder="Ej: C:\backups o \\servidor\compartido"
/> value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<button
type="button"
className="btn btn-secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
onClick={() => {
setExplorerPath(formData.storage_root);
setShowExplorer(true);
}}
>
<FolderOpen size={16} />
Explorar...
</button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}> <p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}>
Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente. Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente.
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio. Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
</p> </p>
{/* UNC Network share configuration */}
<div style={{ marginTop: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '0.84rem' }}>
<input
type="checkbox"
checked={formData.storage_network_enabled || false}
onChange={(e) => setFormData({ ...formData, storage_network_enabled: e.target.checked })}
/>
Conectar a Unidad en Red (UNC) - Requiere credenciales de acceso
</label>
{formData.storage_network_enabled && (
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: '22px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Usuario de Red:</label>
<input
type="text"
className="form-input"
placeholder="ej: Dominio\Usuario o Administrador"
value={formData.storage_network_user || ''}
onChange={(e) => setFormData({ ...formData, storage_network_user: e.target.value })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Contraseña de Red:</label>
<input
type="password"
className="form-input"
placeholder="Contraseña del recurso compartido"
value={formData.storage_network_pass || ''}
onChange={(e) => setFormData({ ...formData, storage_network_pass: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.78rem', padding: '6px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
disabled={testingShare || !formData.storage_root}
onClick={handleTestNetworkShare}
>
{testingShare ? (
<>
<RefreshCw className="animate-spin" size={13} />
Conectando...
</>
) : (
<>
<Globe size={13} />
Probar Conexión de Red
</>
)}
</button>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
@@ -293,6 +424,126 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
)} )}
</button> </button>
</div> </div>
{showExplorer && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '600px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<FolderOpen size={18} color="var(--accent-cyan)" />
Explorador de Archivos del Servidor
</h4>
<button type="button" className="modal-close" onClick={() => setShowExplorer(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ padding: '16px 0', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
disabled={!explorerParentPath}
onClick={() => {
if (explorerParentPath) {
loadExplorerPath(explorerParentPath);
}
}}
title="Subir un nivel"
>
<ArrowUp size={16} />
</button>
<input
type="text"
className="form-input"
style={{ flex: 1 }}
value={explorerPath}
onChange={(e) => setExplorerPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadExplorerPath(explorerPath);
}
}}
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => loadExplorerPath(explorerPath)}
>
Ir
</button>
</div>
{explorerError && (
<div style={{ padding: '10px 14px', backgroundColor: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: '6px', fontSize: '0.8rem', color: '#fca5a5' }}>
{explorerError}
</div>
)}
<div style={{
height: '280px',
overflowY: 'auto',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
backgroundColor: 'rgba(0,0,0,0.15)'
}}>
{explorerLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={24} color="var(--accent-cyan)" />
</div>
) : (
<div style={{ padding: '8px' }}>
{explorerFolders.map((folder) => (
<div
key={folder.path}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
cursor: 'pointer',
borderRadius: '4px',
backgroundColor: hoveredFolder === folder.path ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
transition: 'background-color 0.2s'
}}
onClick={() => setExplorerPath(folder.path)}
onDoubleClick={() => loadExplorerPath(folder.path)}
onMouseEnter={() => setHoveredFolder(folder.path)}
onMouseLeave={() => setHoveredFolder(null)}
>
<FolderOpen size={16} color="var(--accent-cyan)" />
<span style={{ fontSize: '0.86rem' }}>{folder.name}</span>
<ChevronRight size={14} color="var(--text-dim)" style={{ marginLeft: 'auto' }} />
</div>
))}
{explorerFolders.length === 0 && !explorerError && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '200px', color: 'var(--text-dim)', fontSize: '0.86rem' }}>
No hay subcarpetas en este directorio.
</div>
)}
</div>
)}
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowExplorer(false)}>
Cancelar
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setFormData({ ...formData, storage_root: explorerPath });
setShowExplorer(false);
}}
>
Seleccionar Carpeta
</button>
</div>
</div>
</div>
)}
</form> </form>
</div> </div>
); );
+12
View File
@@ -113,6 +113,9 @@ export interface SystemSettingsResponse {
radius_host?: string; radius_host?: string;
radius_port?: number; radius_port?: number;
radius_secret?: string; radius_secret?: string;
storage_network_enabled?: boolean;
storage_network_user?: string;
storage_network_pass?: string;
} }
export interface UserItem { export interface UserItem {
@@ -239,6 +242,15 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
exploreDir: (path?: string) => {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return request<{ current_path: string; parent_path: string | null; folders: Array<{ name: string; path: string }> }>(`/settings/explore-dir${params}`);
},
testNetworkShare: (data: { path: string; username?: string; password?: string }) =>
request<{ message: string }>('/settings/test-network-share', {
method: 'POST',
body: JSON.stringify(data),
}),
// Jobs // Jobs
getJobs: (clientId?: number) => getJobs: (clientId?: number) =>