diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index afab321..b20d86f 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -1,5 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException, status 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.api.deps import require_admin 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"]) +class TestShareRequest(BaseModel): + path: str + username: str + password: str + @router.get("", response_model=SystemSettingsResponse) async def get_settings( 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_weekly": int(settings_dict.get("default_keep_weekly", 4)), "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) @@ -49,4 +68,95 @@ async def update_settings( "default_keep_daily": int(settings_dict.get("default_keep_daily", 7)), "default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)), "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)}" + ) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 7a28aa1..177042e 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -256,6 +256,13 @@ class SystemSettingsResponse(BaseModel): default_keep_daily: int default_keep_weekly: 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): storage_root: Optional[str] = None @@ -264,6 +271,13 @@ class SystemSettingsUpdate(BaseModel): default_keep_daily: Optional[int] = None default_keep_weekly: 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): name: str diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index 0b41320..80f31fe 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -16,8 +16,40 @@ DEFAULT_SETTINGS = { "radius_host": "", "radius_port": "1812", "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: result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) 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: 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(): 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)) setting = result.scalar_one_or_none() if setting: diff --git a/frontend/src/pages/SettingsView.tsx b/frontend/src/pages/SettingsView.tsx index 518f6f0..b5a2a98 100644 --- a/frontend/src/pages/SettingsView.tsx +++ b/frontend/src/pages/SettingsView.tsx @@ -6,7 +6,13 @@ import { Calendar, Loader2, ShieldCheck, - AlertCircle + AlertCircle, + FolderOpen, + ChevronRight, + ArrowUp, + Globe, + RefreshCw, + X } from 'lucide-react'; import { api, SystemSettingsResponse } from '../services/api'; @@ -38,9 +44,58 @@ export const SettingsView: React.FC = ({ onRefresh }) => { auth_mode: 'local', radius_host: '', 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>([]); + const [explorerParentPath, setExplorerParentPath] = useState(null); + const [explorerLoading, setExplorerLoading] = useState(false); + const [explorerError, setExplorerError] = useState(null); + const [testingShare, setTestingShare] = useState(false); + const [hoveredFolder, setHoveredFolder] = useState(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 () => { setLoading(true); setErrorMsg(null); @@ -117,18 +172,94 @@ export const SettingsView: React.FC = ({ onRefresh }) => {
- setFormData({ ...formData, storage_root: e.target.value })} - /> +
+ setFormData({ ...formData, storage_root: e.target.value })} + /> + +
+

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.

+ + {/* UNC Network share configuration */} +
+ + + {formData.storage_network_enabled && ( +
+
+
+ + setFormData({ ...formData, storage_network_user: e.target.value })} + /> +
+
+ + setFormData({ ...formData, storage_network_pass: e.target.value })} + /> +
+
+
+ +
+
+ )} +
@@ -293,6 +424,126 @@ export const SettingsView: React.FC = ({ onRefresh }) => { )} + {showExplorer && ( +
+
+
+

+ + Explorador de Archivos del Servidor +

+ +
+ +
+
+ + setExplorerPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + loadExplorerPath(explorerPath); + } + }} + /> + +
+ + {explorerError && ( +
+ {explorerError} +
+ )} + +
+ {explorerLoading ? ( +
+ +
+ ) : ( +
+ {explorerFolders.map((folder) => ( +
setExplorerPath(folder.path)} + onDoubleClick={() => loadExplorerPath(folder.path)} + onMouseEnter={() => setHoveredFolder(folder.path)} + onMouseLeave={() => setHoveredFolder(null)} + > + + {folder.name} + +
+ ))} + {explorerFolders.length === 0 && !explorerError && ( +
+ No hay subcarpetas en este directorio. +
+ )} +
+ )} +
+
+ +
+ + +
+
+
+ )} ); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index aaf3f10..d57c46e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -113,6 +113,9 @@ export interface SystemSettingsResponse { radius_host?: string; radius_port?: number; radius_secret?: string; + storage_network_enabled?: boolean; + storage_network_user?: string; + storage_network_pass?: string; } export interface UserItem { @@ -239,6 +242,15 @@ export const api = { method: 'PUT', 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 getJobs: (clientId?: number) =>