diff --git a/.env.example b/.env.example index de4263f..e2fc3a1 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ API_HOST=0.0.0.0 API_PORT=8000 API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz +FILE_VFS_ROOT=./data/vfs JWT_SECRET_KEY=change-this-in-production ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=30 diff --git a/api/app/api/router.py b/api/app/api/router.py index 282a757..770445f 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from .v1.admin import router as admin_router +from .v1.admin_files import router as admin_files_router from .v1.auth import router as auth_router from .v1.requirements import router as requirements_router from .v1.users import router as users_router @@ -10,6 +11,7 @@ api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth_router) api_router.include_router(users_router) api_router.include_router(admin_router) +api_router.include_router(admin_files_router) api_router.include_router(requirements_router) api_router.include_router(ws_router) diff --git a/api/app/api/v1/admin_files.py b/api/app/api/v1/admin_files.py new file mode 100644 index 0000000..b0f12bb --- /dev/null +++ b/api/app/api/v1/admin_files.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, File, Query, UploadFile +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from ...core.database import get_db +from ...core.dependencies import CurrentUser, require_any_permission, require_permission +from ...schemas.file_storage import ( + FileCreateDirectoryRequest, + FileDeleteRequest, + FileListResponse, + FileMoveRequest, + FileOperationResponse, + FileRenameRequest, +) +from ...services.file_service import ( + create_directory, + delete_file_path, + download_file_from_path, + list_files, + move_file_path, + rename_file_path, + upload_file_to_path, +) + +router = APIRouter(prefix="/admin/files", tags=["admin-files"]) + + +@router.get("", response_model=FileListResponse) +def get_files( + mount_code: str | None = Query(default=None), + path: str | None = Query(default="/"), + current_user: CurrentUser = Depends(require_any_permission("file.read", "file.manage")), + db: Session = Depends(get_db), +) -> FileListResponse: + return list_files( + db, + actor=current_user.user, + mount_code=mount_code, + path=path, + ) + + +@router.post("/directories", response_model=FileOperationResponse) +def create_directory_endpoint( + payload: FileCreateDirectoryRequest, + current_user: CurrentUser = Depends(require_permission("file.manage")), + db: Session = Depends(get_db), +) -> FileOperationResponse: + return create_directory(db, payload, actor=current_user.user) + + +@router.post("/delete", response_model=FileOperationResponse) +def delete_path_endpoint( + payload: FileDeleteRequest, + current_user: CurrentUser = Depends(require_permission("file.manage")), + db: Session = Depends(get_db), +) -> FileOperationResponse: + return delete_file_path(db, payload, actor=current_user.user) + + +@router.post("/rename", response_model=FileOperationResponse) +def rename_path_endpoint( + payload: FileRenameRequest, + current_user: CurrentUser = Depends(require_permission("file.manage")), + db: Session = Depends(get_db), +) -> FileOperationResponse: + return rename_file_path(db, payload, actor=current_user.user) + + +@router.post("/move", response_model=FileOperationResponse) +def move_path_endpoint( + payload: FileMoveRequest, + current_user: CurrentUser = Depends(require_permission("file.manage")), + db: Session = Depends(get_db), +) -> FileOperationResponse: + return move_file_path(db, payload, actor=current_user.user) + + +@router.post("/upload", response_model=FileOperationResponse) +def upload_file_endpoint( + mount_code: str = Query(..., min_length=2, max_length=64), + parent_path: str = Query(default="/", max_length=2048), + file: UploadFile = File(...), + current_user: CurrentUser = Depends(require_permission("file.manage")), + db: Session = Depends(get_db), +) -> FileOperationResponse: + return upload_file_to_path( + db, + mount_code=mount_code, + parent_path=parent_path, + file=file, + actor=current_user.user, + ) + + +@router.get("/download") +def download_file_endpoint( + mount_code: str = Query(..., min_length=2, max_length=64), + path: str = Query(..., min_length=1, max_length=2048), + _: CurrentUser = Depends(require_any_permission("file.read", "file.manage")), + db: Session = Depends(get_db), +) -> StreamingResponse: + filename, content, content_type = download_file_from_path( + db, + mount_code=mount_code, + path=path, + ) + + media_type = content_type or "application/octet-stream" + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + return StreamingResponse(iter([content]), media_type=media_type, headers=headers) diff --git a/api/app/core/config.py b/api/app/core/config.py index 01ad07e..4fa5d32 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" database_url: str = "sqlite:///./fquiz.db" + file_vfs_root: str = "./data/vfs" jwt_secret_key: str = "change-this-in-production" jwt_algorithm: str = "HS256" diff --git a/api/app/core/database.py b/api/app/core/database.py index b7cd107..041863f 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -39,7 +39,7 @@ def get_db() -> Generator[Session, None, None]: def init_db() -> None: # Import models so metadata includes every table before create_all. - from ..models import audit_log, auth_session, menu, model_registry, rbac, requirement, user # noqa: F401 + from ..models import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user # noqa: F401 from ..services.seed_service import seed_defaults Base.metadata.create_all(bind=engine) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 7bb09cc..ee561c6 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -1 +1,18 @@ -"""Database models.""" +"""Database model package. + +Import all model modules during package initialization so SQLAlchemy can +resolve string-based relationships regardless of route/service import order. +""" + +from . import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user + +__all__ = [ + "audit_log", + "auth_session", + "file_storage", + "menu", + "model_registry", + "rbac", + "requirement", + "user", +] diff --git a/api/app/models/file_storage.py b/api/app/models/file_storage.py new file mode 100644 index 0000000..3c41b8f --- /dev/null +++ b/api/app/models/file_storage.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy import JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +if TYPE_CHECKING: + from .user import User + + +class FileStorageBackend(Base): + __tablename__ = "file_storage_backends" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(128)) + driver_type: Mapped[str] = mapped_column(String(16), index=True) + status: Mapped[str] = mapped_column(String(16), default="enabled", index=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + + mounts: Mapped[list[FileStorageMount]] = relationship( + "FileStorageMount", + back_populates="backend", + lazy="selectin", + cascade="all, delete-orphan", + ) + + +class FileStorageMount(Base): + __tablename__ = "file_storage_mounts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(128)) + backend_id: Mapped[int] = mapped_column( + ForeignKey("file_storage_backends.id", ondelete="CASCADE"), + index=True, + ) + mount_path: Mapped[str] = mapped_column(String(255), default="/", index=True) + root_path: Mapped[str] = mapped_column(String(1024), default="/") + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + + backend: Mapped[FileStorageBackend] = relationship( + "FileStorageBackend", + back_populates="mounts", + lazy="joined", + ) + index_entries: Mapped[list[FileIndexEntry]] = relationship( + "FileIndexEntry", + back_populates="mount", + lazy="selectin", + cascade="all, delete-orphan", + ) + + +class FileIndexEntry(Base): + __tablename__ = "file_index_entries" + __table_args__ = ( + UniqueConstraint("mount_id", "path", name="uq_file_index_mount_path"), + Index("idx_file_index_mount_parent_name", "mount_id", "parent_path", "name"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + mount_id: Mapped[int] = mapped_column( + ForeignKey("file_storage_mounts.id", ondelete="CASCADE"), + index=True, + ) + path: Mapped[str] = mapped_column(String(2048), index=True) + parent_path: Mapped[str] = mapped_column(String(2048), index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + is_dir: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + size: Mapped[int] = mapped_column(BigInteger, default=0) + mime_type: Mapped[str | None] = mapped_column(String(255)) + etag: Mapped[str | None] = mapped_column(String(255)) + storage_key: Mapped[str | None] = mapped_column(String(2048)) + modified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + ) + + mount: Mapped[FileStorageMount] = relationship( + "FileStorageMount", + back_populates="index_entries", + lazy="joined", + ) + last_synced_by_user_id: Mapped[str | None] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + index=True, + ) + last_synced_by_user: Mapped[User | None] = relationship( + "User", + foreign_keys=[last_synced_by_user_id], + lazy="selectin", + ) diff --git a/api/app/schemas/file_storage.py b/api/app/schemas/file_storage.py new file mode 100644 index 0000000..184debb --- /dev/null +++ b/api/app/schemas/file_storage.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + +StorageDriverType = Literal["VFS", "S3"] + + +class FileStorageBackendPublic(BaseModel): + id: int + code: str + name: str + driver_type: StorageDriverType + status: str + is_default: bool + config_summary: dict[str, Any] = Field(default_factory=dict) + + +class FileStorageMountPublic(BaseModel): + id: int + code: str + name: str + mount_path: str + root_path: str + is_enabled: bool + backend: FileStorageBackendPublic + + +class FileBreadcrumbItem(BaseModel): + name: str + path: str + + +class FileEntryPublic(BaseModel): + id: int + path: str + parent_path: str + name: str + is_dir: bool + size: int + mime_type: str | None = None + etag: str | None = None + storage_key: str | None = None + modified_at: datetime | None = None + synced_at: datetime + + +class FileListResponse(BaseModel): + mounts: list[FileStorageMountPublic] + current_mount: FileStorageMountPublic + current_path: str + breadcrumbs: list[FileBreadcrumbItem] + items: list[FileEntryPublic] + total: int + synced_at: datetime + + +class FileCreateDirectoryRequest(BaseModel): + mount_code: str = Field(min_length=2, max_length=64) + parent_path: str = Field(default="/", max_length=2048) + name: str = Field(min_length=1, max_length=255) + + +class FileDeleteRequest(BaseModel): + mount_code: str = Field(min_length=2, max_length=64) + path: str = Field(min_length=1, max_length=2048) + is_dir: bool + recursive: bool = False + + +class FileRenameRequest(BaseModel): + mount_code: str = Field(min_length=2, max_length=64) + path: str = Field(min_length=1, max_length=2048) + is_dir: bool + new_name: str = Field(min_length=1, max_length=255) + + +class FileMoveRequest(BaseModel): + mount_code: str = Field(min_length=2, max_length=64) + path: str = Field(min_length=1, max_length=2048) + is_dir: bool + target_parent_path: str = Field(default="/", max_length=2048) + new_name: str | None = Field(default=None, max_length=255) + + +class FileOperationResponse(BaseModel): + success: bool = True + mount_code: str + path: str + action: str | None = None + target_path: str | None = None diff --git a/api/app/services/admin_service.py b/api/app/services/admin_service.py index f8cabe8..aa4da04 100644 --- a/api/app/services/admin_service.py +++ b/api/app/services/admin_service.py @@ -23,19 +23,17 @@ from .push_service import publish_topic from .user_service import queue_users_auth_refresh -ROLE_LOAD_OPTIONS = ( - selectinload(Role.permissions), - selectinload(Role.menus), -) -MENU_LOAD_OPTIONS = (selectinload(Menu.children),) - - def _role_stmt(): - return select(Role).options(*ROLE_LOAD_OPTIONS) + # Build loader options lazily to avoid triggering mapper configuration + # during module import before all models are registered. + return select(Role).options( + selectinload(Role.permissions), + selectinload(Role.menus), + ) def _menu_stmt(): - return select(Menu).options(*MENU_LOAD_OPTIONS) + return select(Menu).options(selectinload(Menu.children)) def serialize_role(role: Role) -> RolePublic: @@ -305,7 +303,7 @@ def update_menu(db: Session, menu_id: int, payload: MenuUpdateRequest) -> MenuPu def delete_menu(db: Session, menu_id: int) -> bool: menu = get_menu_by_id(db, menu_id) - if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.requirements", "admin.models"}: + if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"}: return False child_exists = db.scalar(select(Menu.id).where(Menu.parent_id == menu_id)) if child_exists is not None: diff --git a/api/app/services/file_service.py b/api/app/services/file_service.py new file mode 100644 index 0000000..b6219e6 --- /dev/null +++ b/api/app/services/file_service.py @@ -0,0 +1,585 @@ +from __future__ import annotations + +import asyncio +import mimetypes +from datetime import datetime + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy import and_, delete, or_, select +from sqlalchemy.orm import Session, joinedload + +from ..models.base import utcnow +from ..models.file_storage import FileIndexEntry, FileStorageBackend, FileStorageMount +from ..models.user import User +from ..schemas.file_storage import ( + FileBreadcrumbItem, + FileCreateDirectoryRequest, + FileDeleteRequest, + FileEntryPublic, + FileListResponse, + FileMoveRequest, + FileOperationResponse, + FileRenameRequest, + FileStorageBackendPublic, + FileStorageMountPublic, +) +from .push_service import publish_topic +from .storage_driver import ( + StorageDriverError, + StorageInvalidPathError, + StorageNotConfiguredError, + StorageObject, + StoragePathNotFoundError, + build_storage_driver, + join_virtual_path, + normalize_virtual_path, +) + +FILES_TOPIC = "admin.files" +FILES_REFETCH_ENDPOINT = "/api/v1/admin/files" + + +def list_files( + db: Session, + *, + actor: User, + mount_code: str | None, + path: str | None, +) -> FileListResponse: + mounts = list_enabled_mounts(db) + if not mounts: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No enabled file mount found") + + current_mount = _pick_mount(mounts, mount_code) + try: + normalized_path = normalize_virtual_path(path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + driver = _build_driver_or_400(current_mount) + + try: + entries = driver.list_dir(normalized_path) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + synced_at = _sync_directory_index( + db, + mount=current_mount, + parent_path=normalized_path, + objects=entries, + actor=actor, + ) + db.commit() + + index_entries = db.execute( + select(FileIndexEntry) + .where(and_(FileIndexEntry.mount_id == current_mount.id, FileIndexEntry.parent_path == normalized_path)) + .order_by(FileIndexEntry.is_dir.desc(), FileIndexEntry.name.asc()) + ).scalars().all() + + return FileListResponse( + mounts=[serialize_mount(item) for item in mounts], + current_mount=serialize_mount(current_mount), + current_path=normalized_path, + breadcrumbs=build_breadcrumbs(normalized_path), + items=[serialize_index_entry(item) for item in index_entries], + total=len(index_entries), + synced_at=synced_at, + ) + + +def create_directory( + db: Session, + payload: FileCreateDirectoryRequest, + *, + actor: User, +) -> FileOperationResponse: + mount = _require_mount(db, payload.mount_code) + driver = _build_driver_or_400(mount) + + try: + parent_path = normalize_virtual_path(payload.parent_path) + target_path = join_virtual_path(parent_path, payload.name) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + try: + driver.ensure_directory(target_path) + entries = driver.list_dir(parent_path) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + _sync_directory_index( + db, + mount=mount, + parent_path=parent_path, + objects=entries, + actor=actor, + ) + db.commit() + _notify_files_changed(action="created_directory", mount_code=mount.code, path=target_path) + + return FileOperationResponse(success=True, mount_code=mount.code, path=target_path, action="created_directory") + + +def delete_file_path( + db: Session, + payload: FileDeleteRequest, + *, + actor: User, +) -> FileOperationResponse: + mount = _require_mount(db, payload.mount_code) + driver = _build_driver_or_400(mount) + + try: + target_path = normalize_virtual_path(payload.path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if target_path == "/": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Root path cannot be deleted") + + try: + driver.delete_path(target_path, is_dir=payload.is_dir, recursive=payload.recursive) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + _delete_index_by_path(db, mount_id=mount.id, target_path=target_path) + + parent_path = _get_parent_path(target_path) + try: + parent_entries = driver.list_dir(parent_path) + except StorageDriverError: + parent_entries = [] + + _sync_directory_index( + db, + mount=mount, + parent_path=parent_path, + objects=parent_entries, + actor=actor, + ) + db.commit() + _notify_files_changed(action="deleted_path", mount_code=mount.code, path=target_path) + + return FileOperationResponse(success=True, mount_code=mount.code, path=target_path, action="deleted_path") + + +def rename_file_path( + db: Session, + payload: FileRenameRequest, + *, + actor: User, +) -> FileOperationResponse: + mount = _require_mount(db, payload.mount_code) + driver = _build_driver_or_400(mount) + + try: + source_path = normalize_virtual_path(payload.path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if source_path == "/": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Root path cannot be renamed") + + try: + target_path = driver.rename_path(source_path, is_dir=payload.is_dir, new_name=payload.new_name) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + _delete_index_by_path(db, mount_id=mount.id, target_path=source_path) + + parent_paths = { + _get_parent_path(source_path), + _get_parent_path(target_path), + } + for parent_path in parent_paths: + try: + parent_entries = driver.list_dir(parent_path) + except StorageDriverError: + parent_entries = [] + _sync_directory_index( + db, + mount=mount, + parent_path=parent_path, + objects=parent_entries, + actor=actor, + ) + + db.commit() + _notify_files_changed(action="renamed_path", mount_code=mount.code, path=target_path) + return FileOperationResponse( + success=True, + mount_code=mount.code, + path=source_path, + action="renamed_path", + target_path=target_path, + ) + + +def move_file_path( + db: Session, + payload: FileMoveRequest, + *, + actor: User, +) -> FileOperationResponse: + mount = _require_mount(db, payload.mount_code) + driver = _build_driver_or_400(mount) + + try: + source_path = normalize_virtual_path(payload.path) + target_parent_path = normalize_virtual_path(payload.target_parent_path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if source_path == "/": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Root path cannot be moved") + + new_name = payload.new_name.strip() if isinstance(payload.new_name, str) else None + + try: + target_path = driver.move_path( + source_path, + is_dir=payload.is_dir, + target_parent_path=target_parent_path, + new_name=new_name, + ) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + _delete_index_by_path(db, mount_id=mount.id, target_path=source_path) + + parent_paths = { + _get_parent_path(source_path), + _get_parent_path(target_path), + } + for parent_path in parent_paths: + try: + parent_entries = driver.list_dir(parent_path) + except StorageDriverError: + parent_entries = [] + _sync_directory_index( + db, + mount=mount, + parent_path=parent_path, + objects=parent_entries, + actor=actor, + ) + + db.commit() + _notify_files_changed(action="moved_path", mount_code=mount.code, path=target_path) + return FileOperationResponse( + success=True, + mount_code=mount.code, + path=source_path, + action="moved_path", + target_path=target_path, + ) + + +def upload_file_to_path( + db: Session, + *, + mount_code: str, + parent_path: str, + file: UploadFile, + actor: User, +) -> FileOperationResponse: + mount = _require_mount(db, mount_code) + driver = _build_driver_or_400(mount) + + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File name is required") + + try: + normalized_parent = normalize_virtual_path(parent_path) + target_path = join_virtual_path(normalized_parent, filename) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + try: + content = file.file.read() + except Exception as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Read upload failed: {exc}") from exc + finally: + try: + file.file.close() + except Exception: + pass + + content_type = file.content_type or mimetypes.guess_type(filename)[0] + + try: + driver.write_file(target_path, content=content, content_type=content_type) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + try: + parent_entries = driver.list_dir(normalized_parent) + except StorageDriverError: + parent_entries = [] + + _sync_directory_index( + db, + mount=mount, + parent_path=normalized_parent, + objects=parent_entries, + actor=actor, + ) + db.commit() + + _notify_files_changed(action="uploaded_file", mount_code=mount.code, path=target_path) + return FileOperationResponse( + success=True, + mount_code=mount.code, + path=target_path, + action="uploaded_file", + ) + + +def download_file_from_path( + db: Session, + *, + mount_code: str, + path: str, +) -> tuple[str, bytes, str | None]: + mount = _require_mount(db, mount_code) + driver = _build_driver_or_400(mount) + + try: + normalized_path = normalize_virtual_path(path) + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + try: + result = driver.read_file(normalized_path) + except StoragePathNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + return result.name, result.content, result.mime_type + + +def list_enabled_mounts(db: Session) -> list[FileStorageMount]: + stmt = ( + select(FileStorageMount) + .join(FileStorageMount.backend) + .options(joinedload(FileStorageMount.backend)) + .where( + and_( + FileStorageMount.is_enabled.is_(True), + FileStorageBackend.status == "enabled", + ) + ) + .order_by(FileStorageBackend.is_default.desc(), FileStorageMount.id.asc()) + ) + return db.execute(stmt).scalars().all() + + +def _pick_mount(mounts: list[FileStorageMount], mount_code: str | None) -> FileStorageMount: + if not mount_code: + return mounts[0] + for mount in mounts: + if mount.code == mount_code: + return mount + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Mount not found: {mount_code}") + + +def _require_mount(db: Session, mount_code: str) -> FileStorageMount: + mounts = list_enabled_mounts(db) + return _pick_mount(mounts, mount_code) + + +def _build_driver_or_400(mount: FileStorageMount): + try: + return build_storage_driver(mount.backend, mount) + except StorageNotConfiguredError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +def _sync_directory_index( + db: Session, + *, + mount: FileStorageMount, + parent_path: str, + objects: list[StorageObject], + actor: User, +) -> datetime: + normalized_parent = normalize_virtual_path(parent_path) + synced_at = utcnow() + + existing_entries = db.execute( + select(FileIndexEntry) + .where(and_(FileIndexEntry.mount_id == mount.id, FileIndexEntry.parent_path == normalized_parent)) + ).scalars().all() + existing_by_path = {item.path: item for item in existing_entries} + incoming_paths = {item.path for item in objects} + + stale_paths = [path for path in existing_by_path if path not in incoming_paths] + for stale_path in stale_paths: + _delete_index_by_path(db, mount_id=mount.id, target_path=stale_path) + + for item in objects: + record = existing_by_path.get(item.path) + if not record: + record = FileIndexEntry( + mount_id=mount.id, + path=item.path, + parent_path=normalized_parent, + name=item.name, + is_dir=item.is_dir, + ) + db.add(record) + + record.parent_path = normalized_parent + record.name = item.name + record.is_dir = item.is_dir + record.size = max(0, int(item.size)) + record.mime_type = item.mime_type + record.etag = item.etag + record.storage_key = item.storage_key + record.modified_at = item.modified_at + record.synced_at = synced_at + record.last_synced_by_user_id = actor.id + + db.flush() + return synced_at + + +def _delete_index_by_path(db: Session, *, mount_id: int, target_path: str) -> None: + normalized = normalize_virtual_path(target_path) + prefix = f"{normalized.rstrip('/')}/%" + db.execute( + delete(FileIndexEntry).where( + and_( + FileIndexEntry.mount_id == mount_id, + or_( + FileIndexEntry.path == normalized, + FileIndexEntry.path.like(prefix), + FileIndexEntry.parent_path == normalized, + ), + ) + ) + ) + + +def build_breadcrumbs(path: str) -> list[FileBreadcrumbItem]: + normalized = normalize_virtual_path(path) + breadcrumbs = [FileBreadcrumbItem(name="根目录", path="/")] + if normalized == "/": + return breadcrumbs + + current = "" + for segment in normalized.strip("/").split("/"): + current = f"{current}/{segment}" + breadcrumbs.append(FileBreadcrumbItem(name=segment, path=current)) + return breadcrumbs + + +def serialize_mount(mount: FileStorageMount) -> FileStorageMountPublic: + return FileStorageMountPublic( + id=mount.id, + code=mount.code, + name=mount.name, + mount_path=mount.mount_path, + root_path=mount.root_path, + is_enabled=mount.is_enabled, + backend=serialize_backend(mount.backend), + ) + + +def serialize_backend(backend: FileStorageBackend) -> FileStorageBackendPublic: + driver_type = backend.driver_type.strip().upper() + config = backend.config_json if isinstance(backend.config_json, dict) else {} + config_summary: dict[str, str] = {} + + if driver_type == "VFS": + root_dir = config.get("root_dir") + if isinstance(root_dir, str): + config_summary["root_dir"] = root_dir + elif driver_type == "S3": + for field in ["bucket", "region_name", "endpoint_url"]: + value = config.get(field) + if isinstance(value, str) and value.strip(): + config_summary[field] = value.strip() + + normalized_driver_type = "S3" if driver_type == "S3" else "VFS" + + return FileStorageBackendPublic( + id=backend.id, + code=backend.code, + name=backend.name, + driver_type=normalized_driver_type, + status=backend.status, + is_default=backend.is_default, + config_summary=config_summary, + ) + + +def serialize_index_entry(entry: FileIndexEntry) -> FileEntryPublic: + return FileEntryPublic( + id=entry.id, + path=entry.path, + parent_path=entry.parent_path, + name=entry.name, + is_dir=entry.is_dir, + size=entry.size, + mime_type=entry.mime_type, + etag=entry.etag, + storage_key=entry.storage_key, + modified_at=entry.modified_at, + synced_at=entry.synced_at, + ) + + +def _get_parent_path(path: str) -> str: + normalized = normalize_virtual_path(path) + if normalized == "/": + return "/" + parent = normalized.rsplit("/", 1)[0] + return parent if parent else "/" + + +def _notify_files_changed(*, action: str, mount_code: str, path: str) -> None: + _fire_and_forget( + publish_topic( + FILES_TOPIC, + name="files.changed", + payload={"action": action, "mount_code": mount_code, "path": path}, + requires_refetch=[FILES_REFETCH_ENDPOINT], + dedupe_key=f"files:{action}:{mount_code}:{path}", + ) + ) + + +def _fire_and_forget(coro: object) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(coro) diff --git a/api/app/services/seed_service.py b/api/app/services/seed_service.py index 85d45ad..de6d741 100644 --- a/api/app/services/seed_service.py +++ b/api/app/services/seed_service.py @@ -2,6 +2,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from ..core.config import get_settings +from ..models.file_storage import FileStorageBackend, FileStorageMount from ..core.security import hash_password from ..models.menu import Menu from ..models.rbac import Permission, Role @@ -19,6 +20,8 @@ DEFAULT_PERMISSIONS: dict[str, str] = { "menu.manage": "Manage menus", "model.read": "Read model registry and routing summary", "model.manage": "Manage model registry, routes, keys, and health checks", + "file.read": "Read file mounts and indexed entries", + "file.manage": "Manage file operations and storage sync", "requirement.read": "Read requirements", "requirement.create": "Create requirements", "requirement.process": "Process requirements", @@ -38,6 +41,8 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = { "menu.manage", "model.read", "model.manage", + "file.read", + "file.manage", "requirement.read", "requirement.create", "requirement.process", @@ -103,6 +108,19 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "cacheable": False, "permission_code": "menu.read", }, + { + "code": "admin.files", + "name": "文件管理", + "path": "/admin/files", + "icon": "FolderOpen", + "parent_code": None, + "type": "menu", + "sort_order": 55, + "status": "enabled", + "visible": True, + "cacheable": False, + "permission_code": "file.read", + }, { "code": "admin.requirements", "name": "需求管理", @@ -132,16 +150,53 @@ DEFAULT_MENUS: list[dict[str, object]] = [ ] ROLE_MENU_BINDINGS: dict[str, list[str]] = { - "admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.requirements", "admin.models"], + "admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"], "user": ["dashboard"], } +DEFAULT_FILE_STORAGE_BACKENDS: list[dict[str, object]] = [ + { + "code": "files.vfs.default", + "name": "本地 VFS 存储", + "driver_type": "VFS", + "status": "enabled", + "is_default": True, + "config_json": lambda: {"root_dir": settings.file_vfs_root}, + }, + { + "code": "files.s3.default", + "name": "S3 对象存储", + "driver_type": "S3", + "status": "disabled", + "is_default": False, + "config_json": { + "bucket": "", + "region_name": "", + "endpoint_url": "", + "access_key_id": "", + "secret_access_key": "", + }, + }, +] + +DEFAULT_FILE_STORAGE_MOUNTS: list[dict[str, object]] = [ + { + "code": "main", + "name": "主文件区", + "backend_code": "files.vfs.default", + "mount_path": "/", + "root_path": "/", + "is_enabled": True, + }, +] + def seed_defaults(db: Session) -> None: permissions = _seed_permissions(db) roles = _seed_roles(db, permissions) menus = _seed_menus(db) _seed_role_menus(db, roles, menus) + _seed_file_storage(db) _seed_initial_admin(db) db.commit() @@ -247,3 +302,60 @@ def _seed_initial_admin(db: Session) -> None: role_codes = {role.code for role in user.roles} if "admin" not in role_codes: user.roles.append(admin_role) + + +def _seed_file_storage(db: Session) -> None: + backend_map: dict[str, FileStorageBackend] = {} + + for backend_info in DEFAULT_FILE_STORAGE_BACKENDS: + code = str(backend_info["code"]) + backend = db.scalar(select(FileStorageBackend).where(FileStorageBackend.code == code)) + config_factory = backend_info.get("config_json") + config_json = config_factory() if callable(config_factory) else config_factory + normalized_config = config_json if isinstance(config_json, dict) else {} + + if not backend: + backend = FileStorageBackend( + code=code, + name=str(backend_info["name"]), + driver_type=str(backend_info["driver_type"]), + status=str(backend_info["status"]), + is_default=bool(backend_info["is_default"]), + config_json=normalized_config, + ) + db.add(backend) + db.flush() + else: + backend.name = str(backend_info["name"]) + backend.driver_type = str(backend_info["driver_type"]) + if not backend.config_json: + backend.config_json = normalized_config + backend_map[code] = backend + + for mount_info in DEFAULT_FILE_STORAGE_MOUNTS: + code = str(mount_info["code"]) + backend_code = str(mount_info["backend_code"]) + backend = backend_map.get(backend_code) + if not backend: + continue + + mount = db.scalar(select(FileStorageMount).where(FileStorageMount.code == code)) + if not mount: + mount = FileStorageMount( + code=code, + name=str(mount_info["name"]), + backend_id=backend.id, + mount_path=str(mount_info["mount_path"]), + root_path=str(mount_info["root_path"]), + is_enabled=bool(mount_info["is_enabled"]), + ) + db.add(mount) + db.flush() + continue + + mount.name = str(mount_info["name"]) + mount.backend_id = backend.id + mount.mount_path = str(mount_info["mount_path"]) + mount.root_path = str(mount_info["root_path"]) + if mount_info.get("is_enabled") is not None: + mount.is_enabled = bool(mount_info["is_enabled"]) diff --git a/api/app/services/storage_driver.py b/api/app/services/storage_driver.py new file mode 100644 index 0000000..3397b6f --- /dev/null +++ b/api/app/services/storage_driver.py @@ -0,0 +1,722 @@ +from __future__ import annotations + +import mimetypes +import shutil +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol + +from ..models.file_storage import FileStorageBackend, FileStorageMount + + +class StorageDriverError(RuntimeError): + pass + + +class StoragePathNotFoundError(StorageDriverError): + pass + + +class StorageInvalidPathError(StorageDriverError): + pass + + +class StorageNotConfiguredError(StorageDriverError): + pass + + +@dataclass(slots=True) +class StorageObject: + path: str + parent_path: str + name: str + is_dir: bool + size: int = 0 + modified_at: datetime | None = None + mime_type: str | None = None + etag: str | None = None + storage_key: str | None = None + + +@dataclass(slots=True) +class StorageReadResult: + path: str + name: str + content: bytes + mime_type: str | None = None + + +class StorageDriver(Protocol): + def list_dir(self, path: str) -> list[StorageObject]: + ... + + def ensure_directory(self, path: str) -> None: + ... + + def delete_path(self, path: str, *, is_dir: bool, recursive: bool) -> None: + ... + + def rename_path(self, path: str, *, is_dir: bool, new_name: str) -> str: + ... + + def move_path(self, path: str, *, is_dir: bool, target_parent_path: str, new_name: str | None) -> str: + ... + + def write_file(self, path: str, *, content: bytes, content_type: str | None = None) -> StorageObject: + ... + + def read_file(self, path: str) -> StorageReadResult: + ... + + +def normalize_virtual_path(path: str | None) -> str: + raw = (path or "/").strip() + if not raw: + return "/" + if not raw.startswith("/"): + raw = f"/{raw}" + + parts: list[str] = [] + for part in raw.split("/"): + if part in {"", "."}: + continue + if part == "..": + raise StorageInvalidPathError("Parent traversal is not allowed") + parts.append(part) + + return f"/{'/'.join(parts)}" if parts else "/" + + +def join_virtual_path(parent_path: str, name: str) -> str: + normalized_parent = normalize_virtual_path(parent_path) + normalized_name = name.strip().strip("/") + if not normalized_name: + raise StorageInvalidPathError("Name cannot be empty") + if "/" in normalized_name or normalized_name in {".", ".."}: + raise StorageInvalidPathError("Invalid directory or file name") + if normalized_parent == "/": + return f"/{normalized_name}" + return f"{normalized_parent}/{normalized_name}" + + +def build_storage_driver(backend: FileStorageBackend, mount: FileStorageMount) -> StorageDriver: + driver_type = backend.driver_type.strip().upper() + config = backend.config_json if isinstance(backend.config_json, dict) else {} + + if driver_type == "VFS": + root_dir = _coerce_non_empty_string(config.get("root_dir")) + if not root_dir: + raise StorageNotConfiguredError("VFS backend requires config.root_dir") + return VfsStorageDriver(root_dir=root_dir, mount_root_path=mount.root_path) + if driver_type == "S3": + return S3StorageDriver(config=config, mount_root_path=mount.root_path) + + raise StorageNotConfiguredError(f"Unsupported storage driver type: {backend.driver_type}") + + +class VfsStorageDriver: + def __init__(self, *, root_dir: str, mount_root_path: str) -> None: + base_root = Path(root_dir).expanduser().resolve() + mount_root = normalize_virtual_path(mount_root_path) + full_root = (base_root / mount_root.lstrip("/")).resolve() + full_root.mkdir(parents=True, exist_ok=True) + + self._base_root = base_root + self._root = full_root + + def list_dir(self, path: str) -> list[StorageObject]: + normalized = normalize_virtual_path(path) + target = self._resolve_target(normalized) + if not target.exists(): + raise StoragePathNotFoundError(f"Path not found: {normalized}") + if not target.is_dir(): + raise StorageInvalidPathError(f"Path is not a directory: {normalized}") + + children = sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())) + result: list[StorageObject] = [] + for child in children: + stat_info = child.stat() + child_path = self._to_virtual_path(child) + modified_at = datetime.fromtimestamp(stat_info.st_mtime, tz=timezone.utc) + mime_type = None if child.is_dir() else mimetypes.guess_type(child.name)[0] + result.append( + StorageObject( + path=child_path, + parent_path=normalized, + name=child.name, + is_dir=child.is_dir(), + size=0 if child.is_dir() else int(stat_info.st_size), + modified_at=modified_at, + mime_type=mime_type, + storage_key=child_path.lstrip("/"), + ) + ) + return result + + def ensure_directory(self, path: str) -> None: + normalized = normalize_virtual_path(path) + target = self._resolve_target(normalized) + target.mkdir(parents=True, exist_ok=True) + + def delete_path(self, path: str, *, is_dir: bool, recursive: bool) -> None: + normalized = normalize_virtual_path(path) + if normalized == "/": + raise StorageInvalidPathError("Root path cannot be deleted") + + target = self._resolve_target(normalized) + if not target.exists(): + raise StoragePathNotFoundError(f"Path not found: {normalized}") + + if is_dir: + if not target.is_dir(): + raise StorageInvalidPathError(f"Path is not a directory: {normalized}") + if recursive: + shutil.rmtree(target) + return + target.rmdir() + return + + if target.is_dir(): + raise StorageInvalidPathError(f"Path is a directory: {normalized}") + target.unlink() + + def rename_path(self, path: str, *, is_dir: bool, new_name: str) -> str: + source = normalize_virtual_path(path) + if source == "/": + raise StorageInvalidPathError("Root path cannot be renamed") + + parent_path = _parent_virtual_path(source) + target_path = join_virtual_path(parent_path, new_name) + if target_path == source: + return source + + source_target = self._resolve_target(source) + if not source_target.exists(): + raise StoragePathNotFoundError(f"Path not found: {source}") + if is_dir and not source_target.is_dir(): + raise StorageInvalidPathError(f"Path is not a directory: {source}") + if not is_dir and source_target.is_dir(): + raise StorageInvalidPathError(f"Path is a directory: {source}") + + target_target = self._resolve_target(target_path) + if target_target.exists(): + raise StorageDriverError(f"Path already exists: {target_path}") + + source_target.rename(target_target) + return target_path + + def move_path(self, path: str, *, is_dir: bool, target_parent_path: str, new_name: str | None) -> str: + source = normalize_virtual_path(path) + if source == "/": + raise StorageInvalidPathError("Root path cannot be moved") + + source_name = _basename_virtual_path(source) + target_name = (new_name or source_name).strip() + target_path = join_virtual_path(target_parent_path, target_name) + if target_path == source: + return source + + if is_dir and target_path.startswith(f"{source.rstrip('/')}/"): + raise StorageInvalidPathError("Directory cannot be moved into itself") + + source_target = self._resolve_target(source) + if not source_target.exists(): + raise StoragePathNotFoundError(f"Path not found: {source}") + if is_dir and not source_target.is_dir(): + raise StorageInvalidPathError(f"Path is not a directory: {source}") + if not is_dir and source_target.is_dir(): + raise StorageInvalidPathError(f"Path is a directory: {source}") + + parent_target = self._resolve_target(normalize_virtual_path(target_parent_path)) + if not parent_target.exists() or not parent_target.is_dir(): + raise StoragePathNotFoundError(f"Path not found: {normalize_virtual_path(target_parent_path)}") + + target_target = self._resolve_target(target_path) + if target_target.exists(): + raise StorageDriverError(f"Path already exists: {target_path}") + + source_target.rename(target_target) + return target_path + + def write_file(self, path: str, *, content: bytes, content_type: str | None = None) -> StorageObject: + normalized = normalize_virtual_path(path) + if normalized == "/": + raise StorageInvalidPathError("Cannot write content to root path") + + parent_path = _parent_virtual_path(normalized) + parent_target = self._resolve_target(parent_path) + if not parent_target.exists() or not parent_target.is_dir(): + raise StoragePathNotFoundError(f"Path not found: {parent_path}") + + target = self._resolve_target(normalized) + if target.exists() and target.is_dir(): + raise StorageInvalidPathError(f"Path is a directory: {normalized}") + + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as output: + output.write(content) + + stat_info = target.stat() + modified_at = datetime.fromtimestamp(stat_info.st_mtime, tz=timezone.utc) + + return StorageObject( + path=normalized, + parent_path=parent_path, + name=target.name, + is_dir=False, + size=int(stat_info.st_size), + modified_at=modified_at, + mime_type=content_type or mimetypes.guess_type(target.name)[0], + storage_key=normalized.lstrip("/"), + ) + + def read_file(self, path: str) -> StorageReadResult: + normalized = normalize_virtual_path(path) + target = self._resolve_target(normalized) + if not target.exists(): + raise StoragePathNotFoundError(f"Path not found: {normalized}") + if target.is_dir(): + raise StorageInvalidPathError(f"Path is a directory: {normalized}") + + content = target.read_bytes() + return StorageReadResult( + path=normalized, + name=target.name, + content=content, + mime_type=mimetypes.guess_type(target.name)[0], + ) + + def _resolve_target(self, normalized_path: str) -> Path: + candidate = (self._root / normalized_path.lstrip("/")).resolve() + if candidate != self._root and self._root not in candidate.parents: + raise StorageInvalidPathError("Resolved path escaped mount root") + return candidate + + def _to_virtual_path(self, absolute_path: Path) -> str: + relative = absolute_path.resolve().relative_to(self._root).as_posix() + return f"/{relative}" if relative else "/" + + +class S3StorageDriver: + def __init__(self, *, config: dict[str, Any], mount_root_path: str) -> None: + try: + import boto3 + except ImportError as exc: + raise StorageNotConfiguredError("S3 driver requires boto3 dependency") from exc + + bucket = _coerce_non_empty_string(config.get("bucket")) + if not bucket: + raise StorageNotConfiguredError("S3 backend requires config.bucket") + + session = boto3.session.Session( + aws_access_key_id=_coerce_non_empty_string(config.get("access_key_id")), + aws_secret_access_key=_coerce_non_empty_string(config.get("secret_access_key")), + aws_session_token=_coerce_non_empty_string(config.get("session_token")), + region_name=_coerce_non_empty_string(config.get("region_name")), + ) + self._client = session.client( + "s3", + endpoint_url=_coerce_non_empty_string(config.get("endpoint_url")), + region_name=_coerce_non_empty_string(config.get("region_name")), + ) + self._bucket = bucket + self._root_prefix = _normalize_s3_prefix(mount_root_path) + + def list_dir(self, path: str) -> list[StorageObject]: + normalized = normalize_virtual_path(path) + prefix = self._key_prefix_for_dir(normalized) + + items: list[StorageObject] = [] + seen_paths: set[str] = set() + + try: + paginator = self._client.get_paginator("list_objects_v2") + pages = paginator.paginate( + Bucket=self._bucket, + Prefix=prefix, + Delimiter="/", + ) + for page in pages: + for common_prefix in page.get("CommonPrefixes", []): + directory_key = str(common_prefix.get("Prefix", "")) + remainder = self._relative_to_parent(directory_key, normalized) + if not remainder: + continue + name = remainder.split("/", 1)[0] + if not name: + continue + directory_path = join_virtual_path(normalized, name) + if directory_path in seen_paths: + continue + seen_paths.add(directory_path) + items.append( + StorageObject( + path=directory_path, + parent_path=normalized, + name=name, + is_dir=True, + size=0, + storage_key=directory_key, + ) + ) + + for content in page.get("Contents", []): + key = str(content.get("Key", "")) + if key == prefix: + continue + remainder = self._relative_to_parent(key, normalized) + if not remainder: + continue + if "/" in remainder: + continue + child_path = join_virtual_path(normalized, remainder) + if child_path in seen_paths: + continue + + is_dir = key.endswith("/") + seen_paths.add(child_path) + items.append( + StorageObject( + path=child_path, + parent_path=normalized, + name=remainder, + is_dir=is_dir, + size=0 if is_dir else int(content.get("Size", 0)), + modified_at=content.get("LastModified"), + etag=str(content.get("ETag", "")).strip('"') or None, + mime_type=mimetypes.guess_type(child_path)[0] if not is_dir else None, + storage_key=key, + ) + ) + except Exception as exc: # pragma: no cover - provider specific errors + if _is_s3_not_found(exc): + raise StoragePathNotFoundError(f"Path not found: {normalized}") from exc + raise StorageDriverError(f"S3 list failed: {exc}") from exc + + items.sort(key=lambda item: (not item.is_dir, item.name.lower())) + return items + + def ensure_directory(self, path: str) -> None: + normalized = normalize_virtual_path(path) + if normalized == "/": + return + + key = self._key_for_path(normalized) + if key and not key.endswith("/"): + key = f"{key}/" + try: + self._client.put_object(Bucket=self._bucket, Key=key, Body=b"") + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 create directory failed: {exc}") from exc + + def delete_path(self, path: str, *, is_dir: bool, recursive: bool) -> None: + normalized = normalize_virtual_path(path) + if normalized == "/": + raise StorageInvalidPathError("Root path cannot be deleted") + + if is_dir: + self._delete_directory(normalized, recursive=recursive) + return + self._delete_object(normalized) + + def rename_path(self, path: str, *, is_dir: bool, new_name: str) -> str: + source = normalize_virtual_path(path) + if source == "/": + raise StorageInvalidPathError("Root path cannot be renamed") + parent = _parent_virtual_path(source) + return self.move_path( + source, + is_dir=is_dir, + target_parent_path=parent, + new_name=new_name, + ) + + def move_path(self, path: str, *, is_dir: bool, target_parent_path: str, new_name: str | None) -> str: + source = normalize_virtual_path(path) + if source == "/": + raise StorageInvalidPathError("Root path cannot be moved") + + target_parent = normalize_virtual_path(target_parent_path) + source_name = _basename_virtual_path(source) + target_name = (new_name or source_name).strip() + target_path = join_virtual_path(target_parent, target_name) + + if target_path == source: + return source + if is_dir and target_path.startswith(f"{source.rstrip('/')}/"): + raise StorageInvalidPathError("Directory cannot be moved into itself") + + if is_dir: + self._move_directory(source, target_path) + else: + self._move_object(source, target_path) + + return target_path + + def write_file(self, path: str, *, content: bytes, content_type: str | None = None) -> StorageObject: + normalized = normalize_virtual_path(path) + if normalized == "/": + raise StorageInvalidPathError("Cannot write content to root path") + + key = self._key_for_path(normalized) + if not key: + raise StorageInvalidPathError("Cannot write content to root path") + if key.endswith("/"): + raise StorageInvalidPathError(f"Path is a directory: {normalized}") + + put_kwargs: dict[str, Any] = { + "Bucket": self._bucket, + "Key": key, + "Body": content, + } + if content_type: + put_kwargs["ContentType"] = content_type + + try: + self._client.put_object(**put_kwargs) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 upload failed: {exc}") from exc + + return StorageObject( + path=normalized, + parent_path=_parent_virtual_path(normalized), + name=_basename_virtual_path(normalized), + is_dir=False, + size=len(content), + mime_type=content_type or mimetypes.guess_type(normalized)[0], + storage_key=key, + ) + + def read_file(self, path: str) -> StorageReadResult: + normalized = normalize_virtual_path(path) + key = self._key_for_path(normalized) + if not key: + raise StorageInvalidPathError("Path is a directory: /") + + try: + response = self._client.get_object(Bucket=self._bucket, Key=key) + except Exception as exc: # pragma: no cover - provider specific errors + if _is_s3_not_found(exc): + raise StoragePathNotFoundError(f"Path not found: {normalized}") from exc + raise StorageDriverError(f"S3 read failed: {exc}") from exc + + body = response.get("Body") + if body is None: + raise StorageDriverError("S3 read failed: empty body") + + try: + content = body.read() + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 read failed: {exc}") from exc + + content_type = response.get("ContentType") + if not isinstance(content_type, str) or not content_type.strip(): + content_type = mimetypes.guess_type(normalized)[0] + + return StorageReadResult( + path=normalized, + name=_basename_virtual_path(normalized), + content=content, + mime_type=content_type, + ) + + def _delete_directory(self, path: str, *, recursive: bool) -> None: + prefix = self._key_prefix_for_dir(path) + keys: list[str] = [] + + try: + paginator = self._client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self._bucket, Prefix=prefix) + for page in pages: + for item in page.get("Contents", []): + key = str(item.get("Key", "")) + if key: + keys.append(key) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 directory listing for deletion failed: {exc}") from exc + + if not keys: + raise StoragePathNotFoundError(f"Path not found: {path}") + + if not recursive: + non_marker = [key for key in keys if key != prefix] + if non_marker: + raise StorageDriverError("Directory is not empty") + + self._delete_keys(keys) + + def _delete_object(self, path: str) -> None: + key = self._key_for_path(path) + self._ensure_object_exists(key, path) + + try: + self._client.delete_object(Bucket=self._bucket, Key=key) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 delete failed: {exc}") from exc + + def _move_object(self, source_path: str, target_path: str) -> None: + source_key = self._key_for_path(source_path) + target_key = self._key_for_path(target_path) + self._ensure_object_exists(source_key, source_path) + self._ensure_object_not_exists(target_key, target_path) + + try: + self._client.copy_object( + Bucket=self._bucket, + Key=target_key, + CopySource={"Bucket": self._bucket, "Key": source_key}, + ) + self._client.delete_object(Bucket=self._bucket, Key=source_key) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 move failed: {exc}") from exc + + def _move_directory(self, source_path: str, target_path: str) -> None: + source_prefix = self._key_prefix_for_dir(source_path) + target_prefix = self._key_prefix_for_dir(target_path) + + if source_prefix == target_prefix: + return + + keys: list[str] = [] + try: + paginator = self._client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self._bucket, Prefix=source_prefix) + for page in pages: + for item in page.get("Contents", []): + key = str(item.get("Key", "")) + if key: + keys.append(key) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 directory listing for move failed: {exc}") from exc + + if not keys: + raise StoragePathNotFoundError(f"Path not found: {source_path}") + + if self._prefix_exists(target_prefix): + raise StorageDriverError(f"Path already exists: {target_path}") + + try: + for key in keys: + suffix = key[len(source_prefix) :] + target_key = f"{target_prefix}{suffix}" + self._client.copy_object( + Bucket=self._bucket, + Key=target_key, + CopySource={"Bucket": self._bucket, "Key": key}, + ) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 directory move copy failed: {exc}") from exc + + self._delete_keys(keys) + + def _prefix_exists(self, prefix: str) -> bool: + if not prefix: + return False + try: + response = self._client.list_objects_v2(Bucket=self._bucket, Prefix=prefix, MaxKeys=1) + except Exception as exc: # pragma: no cover - provider specific errors + raise StorageDriverError(f"S3 exists check failed: {exc}") from exc + return bool(response.get("KeyCount", 0)) + + def _ensure_object_exists(self, key: str, display_path: str) -> None: + try: + self._client.head_object(Bucket=self._bucket, Key=key) + except Exception as exc: # pragma: no cover - provider specific errors + if _is_s3_not_found(exc): + raise StoragePathNotFoundError(f"Path not found: {display_path}") from exc + raise StorageDriverError(f"S3 stat failed: {exc}") from exc + + def _ensure_object_not_exists(self, key: str, display_path: str) -> None: + try: + self._client.head_object(Bucket=self._bucket, Key=key) + raise StorageDriverError(f"Path already exists: {display_path}") + except StorageDriverError: + raise + except Exception as exc: # pragma: no cover - provider specific errors + if _is_s3_not_found(exc): + return + raise StorageDriverError(f"S3 stat failed: {exc}") from exc + + def _delete_keys(self, keys: list[str]) -> None: + chunk_size = 1000 + for index in range(0, len(keys), chunk_size): + chunk = keys[index : index + chunk_size] + self._client.delete_objects( + Bucket=self._bucket, + Delete={"Objects": [{"Key": key} for key in chunk], "Quiet": True}, + ) + + def _key_for_path(self, normalized_path: str) -> str: + relative = normalized_path.strip("/") + if self._root_prefix: + if not relative: + return self._root_prefix.rstrip("/") + return f"{self._root_prefix}{relative}" + return relative + + def _key_prefix_for_dir(self, normalized_path: str) -> str: + if normalized_path == "/": + return self._root_prefix + key = self._key_for_path(normalized_path) + return f"{key}/" if key and not key.endswith("/") else key + + def _relative_to_parent(self, key: str, parent_path: str) -> str | None: + if self._root_prefix and not key.startswith(self._root_prefix): + return None + relative = key[len(self._root_prefix) :] if self._root_prefix else key + relative = relative.strip("/") + if not relative: + return None + + parent_relative = parent_path.strip("/") + if not parent_relative: + return relative + + if relative == parent_relative: + return None + + parent_prefix = f"{parent_relative}/" + if not relative.startswith(parent_prefix): + return None + return relative[len(parent_prefix) :] + + +def _parent_virtual_path(path: str) -> str: + normalized = normalize_virtual_path(path) + if normalized == "/": + return "/" + parent = normalized.rsplit("/", 1)[0] + return parent if parent else "/" + + +def _basename_virtual_path(path: str) -> str: + normalized = normalize_virtual_path(path) + if normalized == "/": + return "" + return normalized.rsplit("/", 1)[-1] + + +def _normalize_s3_prefix(path: str | None) -> str: + normalized = normalize_virtual_path(path) + if normalized == "/": + return "" + return f"{normalized.strip('/')}/" + + +def _coerce_non_empty_string(value: Any) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped if stripped else None + + +def _is_s3_not_found(exc: Exception) -> bool: + response = getattr(exc, "response", None) + if not isinstance(response, dict): + return False + error = response.get("Error") + if not isinstance(error, dict): + return False + code = str(error.get("Code", "")).upper() + return code in {"404", "NOSUCHKEY", "NOTFOUND"} diff --git a/api/app/services/topic_registry.py b/api/app/services/topic_registry.py index 6b94436..689350f 100644 --- a/api/app/services/topic_registry.py +++ b/api/app/services/topic_registry.py @@ -21,6 +21,7 @@ TOPIC_RULES: dict[str, TopicRule] = { "admin.users": TopicRule(any_permission_codes={"user.manage"}), "admin.roles": TopicRule(any_permission_codes={"role.read", "role.manage"}), "admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}), + "admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}), "requirements": TopicRule(any_permission_codes={"requirement.read", "requirement.process", "requirement.manage"}), } diff --git a/api/pyproject.toml b/api/pyproject.toml index e1e43f4..8730336 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -12,4 +12,6 @@ dependencies = [ "PyJWT>=2.10.1,<3.0.0", "argon2-cffi>=23.1.0,<24.0.0", "email-validator>=2.2.0,<3.0.0", + "python-multipart>=0.0.20,<1.0.0", + "boto3>=1.40.0,<2.0.0", ] diff --git a/api/requirements.txt b/api/requirements.txt index 2b72de0..20a4619 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,8 +2,13 @@ fastapi==0.135.3 uvicorn==0.44.0 wsproto==1.3.2 pydantic-settings==2.13.1 +pydantic==2.12.5 +pydantic-core==2.41.5 sqlalchemy==2.0.49 -psycopg[binary]==3.3.3 +psycopg==3.3.3 +psycopg-binary==3.3.3 PyJWT==2.12.1 argon2-cffi==23.1.0 email-validator==2.3.0 +python-multipart==0.0.20 +boto3==1.40.59 diff --git a/web/src/app/admin/files/page.tsx b/web/src/app/admin/files/page.tsx new file mode 100644 index 0000000..86e03f0 --- /dev/null +++ b/web/src/app/admin/files/page.tsx @@ -0,0 +1,674 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { useCallback, useMemo, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; + +import { useAuth } from "@/components/auth-provider"; +import { useTopicSubscription } from "@/hooks/use-topic-subscription"; +import { readApiError } from "@/lib/api"; +import type { + FileEntryItem, + FileListResponse, + FileOperationResponse, + FileStorageMount, +} from "@/types/auth"; + +function formatFileSize(size: number): string { + if (size <= 0) { + return "-"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = size; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = unitIndex === 0 ? 0 : 2; + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +function formatDate(value: string | null): string { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString(); +} + +function buildFilesApiPath(mountCode: string, path: string): string { + const params = new URLSearchParams(); + if (mountCode) { + params.set("mount_code", mountCode); + } + params.set("path", path || "/"); + return `/api/v1/admin/files?${params.toString()}`; +} + +export default function AdminFilesPage() { + const queryClient = useQueryClient(); + const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const [mountCode, setMountCode] = useState(""); + const [currentPath, setCurrentPath] = useState("/"); + const [newDirectoryName, setNewDirectoryName] = useState(""); + const [feedbackMessage, setFeedbackMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [renameName, setRenameName] = useState(""); + const [moveTargetParentPath, setMoveTargetParentPath] = useState("/"); + const [moveNewName, setMoveNewName] = useState(""); + const [activeItemPath, setActiveItemPath] = useState(null); + + const fileInputRef = useRef(null); + + const canRead = hasPermission("file.read") || hasPermission("file.manage"); + const canManage = hasPermission("file.manage"); + + const filesPath = useMemo(() => buildFilesApiPath(mountCode, currentPath), [mountCode, currentPath]); + + const filesQuery = useQuery({ + queryKey: [filesPath], + queryFn: async () => { + const response = await fetchWithAuth(filesPath); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileListResponse; + }, + enabled: !!user && canRead, + }); + + const activeMountCode = filesQuery.data?.current_mount.code ?? mountCode; + + const refreshCurrentPath = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: [filesPath] }); + }, [filesPath, queryClient]); + + const refreshAllFiles = useCallback(async () => { + await queryClient.invalidateQueries({ + predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === "string" && query.queryKey[0].startsWith("/api/v1/admin/files?"), + }); + }, [queryClient]); + + const resetActionPanels = useCallback(() => { + setActiveItemPath(null); + setRenameName(""); + setMoveTargetParentPath(currentPath || "/"); + setMoveNewName(""); + }, [currentPath]); + + const applyMutationSuccess = useCallback(async (payload: FileOperationResponse, fallbackMessage: string) => { + setFeedbackMessage(payload.action ? `操作成功:${payload.action}` : fallbackMessage); + setErrorMessage(""); + resetActionPanels(); + await refreshAllFiles(); + await refreshCurrentPath(); + }, [refreshAllFiles, refreshCurrentPath, resetActionPanels]); + + useTopicSubscription("admin.files", useCallback(() => { + void refreshCurrentPath(); + }, [refreshCurrentPath])); + + const createDirectoryMutation = useMutation({ + mutationFn: async () => { + if (!activeMountCode) { + throw new Error("当前未选择可用挂载点"); + } + const response = await fetchWithAuth("/api/v1/admin/files/directories", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mount_code: activeMountCode, + parent_path: filesQuery.data?.current_path ?? currentPath, + name: newDirectoryName, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileOperationResponse; + }, + onSuccess: async (payload) => { + setNewDirectoryName(""); + await applyMutationSuccess(payload, "目录已创建"); + }, + onError: (error) => { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "目录创建失败"); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (item: FileEntryItem) => { + if (!activeMountCode) { + throw new Error("当前未选择可用挂载点"); + } + const response = await fetchWithAuth("/api/v1/admin/files/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mount_code: activeMountCode, + path: item.path, + is_dir: item.is_dir, + recursive: item.is_dir, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileOperationResponse; + }, + onSuccess: async (payload) => { + await applyMutationSuccess(payload, "路径已删除"); + }, + onError: (error) => { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "删除失败"); + }, + }); + + const renameMutation = useMutation({ + mutationFn: async (item: FileEntryItem) => { + if (!activeMountCode) { + throw new Error("当前未选择可用挂载点"); + } + const response = await fetchWithAuth("/api/v1/admin/files/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mount_code: activeMountCode, + path: item.path, + is_dir: item.is_dir, + new_name: renameName, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileOperationResponse; + }, + onSuccess: async (payload) => { + await applyMutationSuccess(payload, "重命名成功"); + }, + onError: (error) => { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "重命名失败"); + }, + }); + + const moveMutation = useMutation({ + mutationFn: async (item: FileEntryItem) => { + if (!activeMountCode) { + throw new Error("当前未选择可用挂载点"); + } + const response = await fetchWithAuth("/api/v1/admin/files/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mount_code: activeMountCode, + path: item.path, + is_dir: item.is_dir, + target_parent_path: moveTargetParentPath, + new_name: moveNewName.trim() ? moveNewName : undefined, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileOperationResponse; + }, + onSuccess: async (payload) => { + await applyMutationSuccess(payload, "移动成功"); + }, + onError: (error) => { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "移动失败"); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + if (!activeMountCode) { + throw new Error("当前未选择可用挂载点"); + } + const params = new URLSearchParams({ + mount_code: activeMountCode, + parent_path: filesQuery.data?.current_path ?? currentPath, + }); + + const formData = new FormData(); + formData.append("file", file); + + const response = await fetchWithAuth(`/api/v1/admin/files/upload?${params.toString()}`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as FileOperationResponse; + }, + onSuccess: async (payload) => { + await applyMutationSuccess(payload, "上传成功"); + }, + onError: (error) => { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "上传失败"); + }, + }); + + const handleSelectMount = (mount: FileStorageMount) => { + setMountCode(mount.code); + setCurrentPath("/"); + setFeedbackMessage(""); + setErrorMessage(""); + resetActionPanels(); + }; + + const handleOpenDirectory = (item: FileEntryItem) => { + if (!item.is_dir) { + return; + } + setCurrentPath(item.path); + setFeedbackMessage(""); + setErrorMessage(""); + resetActionPanels(); + }; + + const handleDelete = (item: FileEntryItem) => { + const tip = item.is_dir + ? `确认删除目录 ${item.name} 吗?将递归删除目录内全部内容。` + : `确认删除文件 ${item.name} 吗?`; + if (!window.confirm(tip)) { + return; + } + void deleteMutation.mutateAsync(item); + }; + + const startRename = (item: FileEntryItem) => { + setActiveItemPath(item.path); + setRenameName(item.name); + setMoveTargetParentPath(item.parent_path || currentPath || "/"); + setMoveNewName(""); + setFeedbackMessage(""); + setErrorMessage(""); + }; + + const startMove = (item: FileEntryItem) => { + setActiveItemPath(item.path); + setRenameName(""); + setMoveTargetParentPath(currentPath || "/"); + setMoveNewName(item.name); + setFeedbackMessage(""); + setErrorMessage(""); + }; + + const submitRename = (item: FileEntryItem) => { + if (!renameName.trim()) { + setErrorMessage("新名称不能为空"); + return; + } + void renameMutation.mutateAsync(item); + }; + + const submitMove = (item: FileEntryItem) => { + if (!moveTargetParentPath.trim()) { + setErrorMessage("目标目录不能为空"); + return; + } + void moveMutation.mutateAsync(item); + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleUploadChange = (event: ChangeEvent) => { + const selected = event.target.files?.[0]; + if (!selected) { + return; + } + setFeedbackMessage(""); + setErrorMessage(""); + void uploadMutation.mutateAsync(selected); + event.target.value = ""; + }; + + const handleDownload = async (item: FileEntryItem) => { + if (!activeMountCode) { + setErrorMessage("当前未选择可用挂载点"); + return; + } + + try { + const params = new URLSearchParams({ + mount_code: activeMountCode, + path: item.path, + }); + const response = await fetchWithAuth(`/api/v1/admin/files/download?${params.toString()}`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = item.name || "download"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); + setFeedbackMessage(`下载已开始:${item.name}`); + setErrorMessage(""); + } catch (error) { + setFeedbackMessage(""); + setErrorMessage(error instanceof Error ? error.message : "下载失败"); + } + }; + + const listError = filesQuery.error instanceof Error ? filesQuery.error.message : ""; + const listData = filesQuery.data; + const mounts = listData?.mounts ?? []; + const items = listData?.items ?? []; + const operationBusy = + createDirectoryMutation.isPending || + deleteMutation.isPending || + renameMutation.isPending || + moveMutation.isPending || + uploadMutation.isPending; + + if (initializing || filesQuery.isLoading) { + return

Loading files...

; + } + + if (!user) { + return ( +
+

请先登录后再访问文件管理页面。

+ 返回首页 +
+ ); + } + + if (!canRead) { + return ( +
+

你没有访问该页面的权限(需要 `file.read`)。

+ 返回首页 +
+ ); + } + + return ( +
+ {(listError || errorMessage) && ( +
+          {listError || errorMessage}
+        
+ )} + {feedbackMessage && ( +
+          {feedbackMessage}
+        
+ )} + +
+
+

挂载点

+

一期按挂载点浏览目录树,支持 VFS/S3。

+
+ {mounts.map((mount) => { + const selected = mount.code === (listData?.current_mount.code ?? mountCode); + return ( + + ); + })} + {mounts.length === 0 && ( +

暂无可用挂载点。

+ )} +
+
+ +
+
+
+

文件列表

+

+ 存储后端:{listData?.current_mount.backend.name ?? "-"}({listData?.current_mount.backend.driver_type ?? "-"}) +

+
+
+ + {canManage && ( + <> + + + + )} +
+
+ +
+ {(listData?.breadcrumbs ?? [{ name: "根目录", path: "/" }]).map((crumb, index, all) => ( +
+ + {index < all.length - 1 && /} +
+ ))} +
+ + {canManage && ( +
+ setNewDirectoryName(event.target.value)} + placeholder="新建目录名" + className="w-full max-w-xs rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" + /> + +
+ )} + +
+ + + + + + + + + + + + + {items.map((item) => { + const isActive = activeItemPath === item.path; + return ( + + + + + + + + + ); + })} + {items.length === 0 && ( + + + + )} + +
名称类型大小修改时间索引同步时间操作
+ + {isActive && canManage && ( +
+
+ setRenameName(event.target.value)} + placeholder="新名称" + className="w-48 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" + /> + +
+
+ setMoveTargetParentPath(event.target.value)} + placeholder="目标目录(如 /a/b)" + className="w-48 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" + /> + setMoveNewName(event.target.value)} + placeholder="新名称(可选)" + className="w-40 rounded-md border border-black/15 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40" + /> + + +
+
+ )} +
{item.is_dir ? "目录" : item.mime_type ?? "文件"}{item.is_dir ? "-" : formatFileSize(item.size)}{formatDate(item.modified_at)}{formatDate(item.synced_at)} +
+ {item.is_dir && ( + + )} + {!item.is_dir && ( + + )} + {canManage && ( + <> + + + + + )} +
+
+ 当前目录为空 +
+
+
+
+
+ ); +} diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index 0a08218..96427ca 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -35,7 +35,7 @@ export default function AdminMenusPage() { const canRead = hasPermission("menu.read") || hasPermission("menu.manage"); const canManage = hasPermission("menu.manage"); - const protectedMenuCodes = new Set(["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.models"]); + const protectedMenuCodes = new Set(["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"]); const parentOptions = useMemo(() => menus.map((menu) => ({ id: menu.id, label: `${menu.name} (${menu.code})` })), [menus]); const loadMenus = useCallback(async () => { diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx index d641d4e..d63d887 100644 --- a/web/src/app/admin/page.tsx +++ b/web/src/app/admin/page.tsx @@ -23,6 +23,12 @@ const CARDS = [ description: "维护后台导航、菜单层级和菜单对应权限。", visible: (hasPermission: (code: string) => boolean) => hasPermission("menu.read") || hasPermission("menu.manage"), }, + { + href: "/admin/files", + title: "文件管理", + description: "管理挂载点文件列表、目录浏览、目录创建和删除。", + visible: (hasPermission: (code: string) => boolean) => hasPermission("file.read") || hasPermission("file.manage"), + }, { href: "/admin/requirements", title: "需求管理", diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 04aa2e3..7c35295 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -65,6 +65,65 @@ export type MenuListResponse = { total: number; }; +export type FileStorageDriverType = "VFS" | "S3"; + +export type FileStorageBackendSummary = { + id: number; + code: string; + name: string; + driver_type: FileStorageDriverType; + status: string; + is_default: boolean; + config_summary: Record; +}; + +export type FileStorageMount = { + id: number; + code: string; + name: string; + mount_path: string; + root_path: string; + is_enabled: boolean; + backend: FileStorageBackendSummary; +}; + +export type FileBreadcrumbItem = { + name: string; + path: string; +}; + +export type FileEntryItem = { + id: number; + path: string; + parent_path: string; + name: string; + is_dir: boolean; + size: number; + mime_type: string | null; + etag: string | null; + storage_key: string | null; + modified_at: string | null; + synced_at: string; +}; + +export type FileListResponse = { + mounts: FileStorageMount[]; + current_mount: FileStorageMount; + current_path: string; + breadcrumbs: FileBreadcrumbItem[]; + items: FileEntryItem[]; + total: number; + synced_at: string; +}; + +export type FileOperationResponse = { + success: boolean; + mount_code: string; + path: string; + action: string | null; + target_path: string | null; +}; + export type ModelStatus = "DRAFT" | "ENABLED" | "DISABLED" | "DEPRECATED"; export type ModelRouteType = "GLOBAL" | "CAPABILITY" | "BUSINESS" | "AGENT"; export type ModelHealthStatus = "HEALTHY" | "DEGRADED" | "UNHEALTHY";