feat: Add API endpoint to list node files

Implement issue #2719 - Add API endpoint to list project files

- Add GET /v3/projects/{project_id}/nodes/{node_id}/files endpoint
- Add list_node_files() method to Project class
- Add security checks to prevent path traversal
- Filter out .ghost temporary files
- Return file paths with MD5 checksums
- Require Node.Audit privilege

This allows users to discover dynamically created files
such as QEMU disk images created via the disk image API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
YueGuobin 2026-05-08 22:52:17 +08:00
parent e0860555ba
commit 5b75d633aa
No known key found for this signature in database
3 changed files with 69 additions and 0 deletions

View File

@ -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.ProjectFile])
async def get_compute_node_files(
node_type: str,
node_id: str,
project: Project = Depends(dep_project)
) -> List[schemas.ProjectFile]:
"""
Return files belonging to a specific node.
"""
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:
"""

View File

@ -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.ProjectFile], dependencies=[Depends(has_privilege("Node.Audit"))])
async def list_node_files(node: Node = Depends(dep_node)) -> List[schemas.ProjectFile]:
"""
List files in a node directory.
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:
"""

View File

@ -20,6 +20,7 @@ import asyncio
import hashlib
from uuid import UUID, uuid4
from fastapi import HTTPException
from gns3server.compute.compute_error import ComputeError, ComputeNotFoundError, ComputeForbiddenError
from .port_manager import PortManager
@ -417,6 +418,43 @@ class Project:
return files
async def list_node_files(self, node_path: str):
"""
List files in a specific node directory.
:param node_path: Relative path to node directory (e.g., "project-files/qemu/node-id")
:returns: Array of files in the node directory
"""
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:
for filename in os.listdir(node_full_path):
file_path = os.path.join(node_full_path, filename)
if os.path.isfile(file_path) and not filename.endswith(".ghost"):
file_info = {"path": filename}
try:
file_info["md5sum"] = await wait_run_in_executor(
self._hash_file, file_path
)
except OSError:
continue
files.append(file_info)
except OSError as e:
log.error(f"Error listing node files: {e}")
return files
def _hash_file(self, path):
"""
Compute and md5 hash for file