eigent/backend/app/controller/file_controller.py

498 lines
16 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import asyncio
import logging
import mimetypes
import re
import time
from functools import partial
from pathlib import Path
from typing import Annotated
from urllib.parse import quote
from fastapi import (
APIRouter,
Depends,
File,
Header,
HTTPException,
Query,
UploadFile,
)
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from app.artifacts import (
discover_task_changed_files,
scan_task_changed_files,
task_modification_windows,
)
from app.auth import require_local_control_principal
from app.component.environment import env
from app.run_journal import get_default_run_journal
from app.utils.file_utils import list_files, resolve_under_base
from app.utils.workspace_paths import runtime_owner_key, task_dir_name
from app.utils.workspace_resolver import get_workspace_resolver
router = APIRouter()
file_logger = logging.getLogger("file_controller")
# Config
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB
MAX_FILES_PER_SESSION = 20
WORKSPACE_ROOT = env("EIGENT_WORKSPACE", "~/.eigent/workspace")
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
FILE_LIST_SEMAPHORE = asyncio.Semaphore(4)
SLOW_FILE_LIST_LOG_MS = 300
def _get_eigent_root() -> Path:
"""Base root for eigent storage (~/eigent). Do NOT use env file_save_path
here: chat overwrites it to task path, which would break list/stream."""
eigent = Path.home() / "eigent"
if eigent.exists():
return eigent
dot_eigent = Path.home() / ".eigent"
if dot_eigent.exists():
return dot_eigent
return eigent # default to ~/eigent
def _get_workspace_root() -> Path:
return Path(WORKSPACE_ROOT).expanduser()
def _validate_session_id(session_id: str) -> str:
normalized = (session_id or "").strip()
if not SESSION_ID_PATTERN.fullmatch(normalized):
raise ValueError("Invalid X-Session-ID")
return normalized
def _get_session_uploads_dir(session_id: str) -> Path:
root = _get_workspace_root().resolve()
validated = _validate_session_id(session_id)
uploads_dir = (root / validated / "uploads").resolve()
try:
uploads_dir.relative_to(root)
except ValueError as exc:
raise ValueError("Invalid X-Session-ID") from exc
return uploads_dir
def _count_session_uploads(session_id: str) -> int:
uploads_dir = _get_session_uploads_dir(session_id)
if not uploads_dir.exists():
return 0
return len(list(uploads_dir.iterdir()))
def _redacted_path_suffix(path: Path) -> str:
parts = path.parts[-2:]
return (".../" + "/".join(parts)) if parts else "..."
@router.post("/files")
async def upload_file(
file: Annotated[UploadFile, File()],
x_session_id: Annotated[str | None, Header(alias="X-Session-ID")] = None,
) -> dict:
"""
Upload file. Requires X-Session-ID header.
Returns file_id for message attachments reference.
"""
if not x_session_id:
raise HTTPException(
status_code=400,
detail="X-Session-ID header is required for file upload",
)
try:
validated_session_id = _validate_session_id(x_session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid X-Session-ID"
) from exc
# Check session file count limit
count = _count_session_uploads(validated_session_id)
if count >= MAX_FILES_PER_SESSION:
raise HTTPException(
status_code=429,
detail=f"Maximum {MAX_FILES_PER_SESSION} files per session",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=413,
detail=f"File size exceeds {MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB limit",
)
# Generate safe filename
timestamp = int(time.time() * 1000)
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_"
for c in (file.filename or "file")
)
stored_name = f"{safe_name}_{timestamp}"
# Write to disk
uploads_dir = _get_session_uploads_dir(validated_session_id)
uploads_dir.mkdir(parents=True, exist_ok=True)
target_path = uploads_dir / stored_name
target_path.write_bytes(content)
file_id = f"upload://{stored_name}"
file_logger.info(
f"File uploaded: session={validated_session_id}, file_id={file_id}, size={len(content)}"
)
return {
"file_id": file_id,
"filename": file.filename or "file",
"size": len(content),
}
def _normalize_relative_path(path: str) -> str:
"""Normalize relative path to URL-safe POSIX style."""
return path.replace("\\", "/")
def _get_project_root(
email: str, project_id: str, user_id: str | int | None = None
) -> Path:
"""Get project root path under the canonical user_id or legacy email key."""
return (
_get_eigent_root()
/ runtime_owner_key(email, user_id)
/ f"project_{project_id}"
)
def _resolve_project_root(
email: str, project_id: str, user_id: str | int | None = None
) -> Path:
"""
Resolve project root, preferring the canonical user_id-scoped path when
available, then legacy email-scoped storage.
"""
preferred = _get_project_root(email, project_id, user_id)
if preferred.exists():
return preferred
if user_id is not None and str(user_id).strip():
legacy_preferred = _get_project_root(email, project_id)
if legacy_preferred.exists():
return legacy_preferred
return preferred
def _resolve_file_root(
email: str,
project_id: str,
space_id: str | None = None,
user_id: str | int | None = None,
) -> Path:
if space_id:
resolver = get_workspace_resolver()
space_root = resolver.space_root(
space_id=space_id,
project_id=project_id,
email=email,
user_id=user_id,
)
if space_root is not None:
return space_root
return _resolve_project_root(email, project_id, user_id)
def _list_task_changed_files(
snapshot,
max_entries: int = 500,
modification_windows: tuple[tuple[float, float | None], ...] | None = None,
) -> list[dict]:
"""List files generated or modified by one Run without uploading them."""
return scan_task_changed_files(
snapshot,
max_entries,
modification_windows,
list_files_fn=list_files,
)
def _task_modification_windows(
task_id: str,
project_id: str,
) -> tuple[tuple[tuple[float, float | None], ...] | None, bool]:
"""Return filesystem-mtime windows owned by this Run's attempts.
``None`` means the RunJournal has no matching canonical Run and callers
should retain the legacy start-time-only behavior.
"""
return task_modification_windows(
get_default_run_journal(), task_id, project_id
)
@router.get(
"/files/changes",
dependencies=[Depends(require_local_control_principal)],
)
async def list_task_changed_files(
task_id: str = Query(..., description="Run/task ID"),
project_id: str = Query(..., description="Project ID"),
email: str = Query(..., description="User email"),
user_id: str | None = Query(
None, description="Optional canonical user ID"
),
) -> dict:
"""Return the Desktop-local preview index for one Run's changed files."""
manifest_event = await run_in_threadpool(
get_default_run_journal().get_run_artifact_manifest_event,
task_id,
)
if manifest_event is not None:
artifacts = manifest_event.payload.get("artifacts", [])
if isinstance(artifacts, list):
return {
"artifacts": [
dict(item) for item in artifacts if isinstance(item, dict)
],
"scan_status": str(
manifest_event.payload.get("scan_status") or "complete"
),
"truncated": bool(manifest_event.payload.get("truncated")),
}
snapshot = get_workspace_resolver().store.get_snapshot(
email, task_id, user_id
)
if snapshot is None or snapshot.project_id != project_id:
raise HTTPException(status_code=404, detail="Task workspace not found")
artifact_manifest = getattr(snapshot, "artifact_manifest", None)
if artifact_manifest is not None:
return {
"artifacts": [dict(item) for item in artifact_manifest],
"scan_status": "complete",
"truncated": False,
}
windows, permanently_terminal = await run_in_threadpool(
_task_modification_windows, task_id, project_id
)
scan_result = await run_in_threadpool(
discover_task_changed_files,
snapshot,
max_entries=500,
modification_windows=windows,
)
artifacts = scan_result.artifacts
if permanently_terminal:
await run_in_threadpool(
get_workspace_resolver().store.freeze_artifact_manifest,
email,
snapshot,
artifacts,
)
return {
"artifacts": artifacts,
"scan_status": scan_result.scan_status,
"truncated": scan_result.truncated,
}
@router.get("/files")
async def list_project_files(
project_id: str = Query(..., description="Project ID"),
email: str = Query(..., description="User email"),
space_id: str | None = Query(None, description="Optional Space ID"),
user_id: str | None = Query(
None, description="Optional canonical user ID"
),
task_id: str | None = Query(
None, description="Optional task ID to scope listing"
),
) -> list[dict]:
"""
List files in project working directory (Brain storage).
Used by Web mode when ipcRenderer is unavailable.
Returns [{filename, url}] where url can be used to fetch file content.
"""
if not project_id or not email:
raise HTTPException(
status_code=400,
detail="project_id and email are required",
)
project_root = _resolve_file_root(email, project_id, space_id, user_id)
list_dir = str(project_root)
if task_id:
list_dir = str(project_root / task_dir_name(task_id))
if not Path(list_dir).exists():
file_logger.debug(
"list_project_files: path does not exist: %s",
list_dir,
)
return []
base_path = str(project_root.resolve())
stats: dict[str, float | int] = {}
started = time.perf_counter()
try:
async with FILE_LIST_SEMAPHORE:
paths = await run_in_threadpool(
partial(
list_files,
list_dir,
base=base_path,
max_entries=500,
stats=stats,
)
)
except Exception as e:
file_logger.warning("list_project_files failed: %s", e)
return []
elapsed_ms = (time.perf_counter() - started) * 1000
log = (
file_logger.info
if elapsed_ms >= SLOW_FILE_LIST_LOG_MS
else file_logger.debug
)
log(
"list_project_files: project_id=%s space_id=%s task_id=%s count=%d "
"elapsed_ms=%.1f scan_ms=%.1f realpath_ms=%.1f symlinks=%d root=%s",
project_id,
space_id,
task_id,
len(paths),
elapsed_ms,
float(stats.get("scan_elapsed_ms", 0)),
float(stats.get("realpath_elapsed_ms", 0)),
int(stats.get("symlink_count", 0)),
_redacted_path_suffix(project_root),
)
result: list[dict] = []
for abs_path in paths:
try:
rel = _normalize_relative_path(
Path(abs_path).relative_to(base_path).as_posix()
)
# URL-encode the relative path for stream endpoint
path_param = quote(rel, safe="")
stat_result = Path(abs_path).stat()
result.append(
{
"filename": Path(abs_path).name,
"url": (
f"/files/stream?path={path_param}"
f"&project_id={quote(project_id)}"
f"&email={quote(email)}"
+ (f"&space_id={quote(space_id)}" if space_id else "")
+ (f"&user_id={quote(user_id)}" if user_id else "")
),
"relativePath": rel,
"size": stat_result.st_size,
"modifiedAt": stat_result.st_mtime * 1000,
"supportsRanges": True,
}
)
except (ValueError, OSError):
continue
return result
@router.api_route("/files/stream", methods=["GET", "HEAD"])
async def stream_file(
path: str = Query(..., description="Relative path from project root"),
project_id: str = Query(..., description="Project ID"),
email: str = Query(..., description="User email"),
space_id: str | None = Query(None, description="Optional Space ID"),
user_id: str | None = Query(
None, description="Optional canonical user ID"
),
):
"""
Stream file content. Path must be relative to project root.
Used by Web mode to fetch file content for display.
"""
if not path or not project_id or not email:
raise HTTPException(
status_code=400,
detail="path, project_id and email are required",
)
project_root = _resolve_file_root(email, project_id, space_id, user_id)
# Resolve path and ensure it stays under project root (security)
try:
resolved = resolve_under_base(path, str(project_root.resolve()))
except Exception as e:
file_logger.warning("stream_file path validation failed: %s", e)
raise HTTPException(status_code=400, detail="Invalid path") from e
p = Path(resolved)
if not p.is_file():
raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(str(p))
if not media_type:
media_type = "application/octet-stream"
# content_disposition_type=inline: display in iframe instead of triggering download
return FileResponse(
path=str(p),
filename=p.name,
media_type=media_type,
content_disposition_type="inline",
)
@router.get("/files/preview/{email}/{project_id}/{file_path:path}")
async def preview_file(
email: str,
project_id: str,
file_path: str,
space_id: str | None = Query(None, description="Optional Space ID"),
user_id: str | None = Query(
None, description="Optional canonical user ID"
),
):
"""
Preview file content with a path-based URL so relative references inside
HTML/CSS/JS resolve against the project directory structure.
"""
if not file_path or not project_id or not email:
raise HTTPException(
status_code=400,
detail="file_path, project_id and email are required",
)
project_root = _resolve_file_root(email, project_id, space_id, user_id)
try:
resolved = resolve_under_base(file_path, str(project_root.resolve()))
except Exception as e:
file_logger.warning("preview_file path validation failed: %s", e)
raise HTTPException(status_code=400, detail="Invalid path") from e
p = Path(resolved)
if not p.is_file():
raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(str(p))
if not media_type:
media_type = "application/octet-stream"
return FileResponse(
path=str(p),
filename=p.name,
media_type=media_type,
content_disposition_type="inline",
)