diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index eca97c204..711e78416 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -192,6 +192,20 @@ async def get_compute_project_files(project: Project = Depends(dep_project)) -> return await project.list_files() +@router.get("/projects/{project_id}/nodes/{node_type}/{node_id}/files", response_model=List[schemas.NodeFile]) +async def get_compute_node_files( + node_type: str, + node_id: str, + project: Project = Depends(dep_project) +) -> List[schemas.NodeFile]: + """ + Return files belonging to a specific node with detailed metadata. + """ + + node_path = f"project-files/{node_type}/{node_id}" + return await project.list_node_files(node_path) + + @router.get("/projects/{project_id}/files/{file_path:path}") async def get_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse: """ diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index f6314e152..0ee624ce4 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -522,6 +522,23 @@ async def delete_disk_image( await node.delete(f"/disk_image/{disk_name}") +@router.get("/{node_id}/files", response_model=List[schemas.NodeFile], dependencies=[Depends(has_privilege("Node.Audit"))]) +async def list_node_files(node: Node = Depends(dep_node)) -> List[schemas.NodeFile]: + """ + List files in a node directory with detailed metadata. + + Required privilege: Node.Audit + """ + + node_type = node.node_type + res = await node.compute.http_query( + "GET", + f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files", + timeout=None + ) + return res.json + + @router.get("/{node_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Node.Audit"))]) async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response: """ diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index 38ae07e85..175d9e916 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -18,8 +18,10 @@ import os import shutil import asyncio import hashlib +import datetime from uuid import UUID, uuid4 +from fastapi import HTTPException, status from gns3server.compute.compute_error import ComputeError, ComputeNotFoundError, ComputeForbiddenError from .port_manager import PortManager @@ -417,6 +419,67 @@ class Project: return files + async def list_node_files(self, node_path: str): + """ + List files in a specific node directory with detailed metadata. + + :param node_path: Relative path to node directory (e.g., "project-files/qemu/node-id") + :returns: Array of files in the node directory with metadata + """ + + node_full_path = os.path.normpath(os.path.join(self.path, node_path)) + + # Security check: ensure the path is within the project directory + if not os.path.commonpath([node_full_path, self.path]) == self.path: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, + detail="Path is outside the project directory") + + if not os.path.exists(node_full_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Node directory not found") + + files = [] + try: + filenames = os.listdir(node_full_path) + except OSError as e: + log.error(f"Error listing node directory: {e}") + return files + + for filename in filenames: + file_path = os.path.join(node_full_path, filename) + if not os.path.isfile(file_path) or filename.endswith(".ghost"): + continue + + try: + # Get file stat information + stat_info = await wait_run_in_executor(os.stat, file_path) + + # Get file extension + _, extension = os.path.splitext(filename) + extension = extension.lstrip('.') + + # Format timestamps as ISO 8601 + try: + created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() + modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() + except (OSError, OverflowError, ValueError) as e: + log.warning(f"Invalid timestamp for '{filename}': {e}") + created_at = modified_at = "" + + file_info = { + "path": filename, + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "extension": extension + } + files.append(file_info) + except OSError as e: + log.warning(f"Error getting metadata for file '{filename}': {e}") + continue + + return files + def _hash_file(self, path): """ Compute and md5 hash for file diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index f68c194ca..0c58eee28 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -28,7 +28,7 @@ from .controller.appliances import ApplianceVersion, ApplianceVersionV8, Applian from .controller.drawings import Drawing from .controller.gns3vm import GNS3VM from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node -from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression +from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression, NodeFile from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup # Conditionally import AI-related schemas diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index 711eabb6b..f537b122f 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -105,6 +105,18 @@ class ProjectFile(BaseModel): md5sum: str = Field(..., description="File checksum") +class NodeFile(BaseModel): + """ + Detailed file information for node files. + """ + + path: str = Field(..., description="File name") + size: int = Field(..., description="File size in bytes") + created_at: str = Field(..., description="File creation time (ISO 8601)") + modified_at: str = Field(..., description="File modification time (ISO 8601)") + extension: str = Field(..., description="File extension") + + class ProjectCompression(str, Enum): """ Supported project compression.