diff --git a/.gitignore b/.gitignore index ccaad04..08ae385 100644 --- a/.gitignore +++ b/.gitignore @@ -217,6 +217,7 @@ build/Release # Dependency directories node_modules/ +**/node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/README.md b/README.md index 867e391..138987f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ # govoplan-files +GovOPlaN Files module. + +This repository owns the Files module backend manifest, API router, schemas, storage service layer, SQLAlchemy model definitions, migration registration path, and Files WebUI source bundle. Runtime access to auth/session/settings and cross-module models is provided by govoplan-core and the installed module packages. + +The WebUI bundle is exposed as the local npm package `@govoplan/files-webui` from `webui/`. It depends on `@govoplan/core-webui` for shared API/CSRF/auth helpers, core types, generic UI components and shell primitives. + +## Development + +Install the backend module through the core development environment: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +Install the WebUI host dependencies from the core WebUI runner: + +```bash +cd /mnt/DATA/git/govoplan-core/webui +PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install +``` + +The backend module is registered through the `govoplan.modules` entry point `files`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..96943b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-files" +version = "0.1.0" +description = "GovOPlaN files module with backend and WebUI integration." +readme = "README.md" +requires-python = ">=3.12" +license = { file = "LICENSE" } +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_files = ["py.typed"] + +[project.entry-points."govoplan.modules"] +files = "govoplan_files.backend.manifest:get_manifest" diff --git a/src/govoplan_files/__init__.py b/src/govoplan_files/__init__.py new file mode 100644 index 0000000..ceb8df1 --- /dev/null +++ b/src/govoplan_files/__init__.py @@ -0,0 +1 @@ +"""govoplan-files module package.""" diff --git a/src/govoplan_files/backend/__init__.py b/src/govoplan_files/backend/__init__.py new file mode 100644 index 0000000..5ab5106 --- /dev/null +++ b/src/govoplan_files/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for this GovOPlaN module.""" diff --git a/src/govoplan_files/backend/db/__init__.py b/src/govoplan_files/backend/db/__init__.py new file mode 100644 index 0000000..a4430b7 --- /dev/null +++ b/src/govoplan_files/backend/db/__init__.py @@ -0,0 +1 @@ +from govoplan_files.backend.db.models import * diff --git a/src/govoplan_files/backend/db/models.py b/src/govoplan_files/backend/db/models.py new file mode 100644 index 0000000..69c2c94 --- /dev/null +++ b/src/govoplan_files/backend/db/models.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class FileBlob(Base, TimestampMixin): + __tablename__ = "file_blobs" + __table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + storage_backend: Mapped[str] = mapped_column(String(50), nullable=False) + storage_bucket: Mapped[str | None] = mapped_column(String(255)) + storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + ref_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + retained_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class FileFolder(Base, TimestampMixin): + __tablename__ = "file_folders" + __table_args__ = ( + Index( + "uq_file_folders_active_user_path", + "tenant_id", "owner_user_id", "path", + unique=True, + sqlite_where=text("owner_type = 'user' AND deleted_at IS NULL"), + postgresql_where=text("owner_type = 'user' AND deleted_at IS NULL"), + ), + Index( + "uq_file_folders_active_group_path", + "tenant_id", "owner_group_id", "path", + unique=True, + sqlite_where=text("owner_type = 'group' AND deleted_at IS NULL"), + postgresql_where=text("owner_type = 'group' AND deleted_at IS NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class FileAsset(Base, TimestampMixin): + __tablename__ = "file_assets" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) + filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class FileVersion(Base, TimestampMixin): + __tablename__ = "file_versions" + __table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) + blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + filename_at_upload: Mapped[str] = mapped_column(String(500), nullable=False) + display_path_at_upload: Mapped[str] = mapped_column(String(1000), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + +class FileShare(Base, TimestampMixin): + __tablename__ = "file_shares" + __table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) + target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + +class CampaignAttachmentUse(Base, TimestampMixin): + __tablename__ = "campaign_attachment_uses" + __table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + entry_index: Mapped[int | None] = mapped_column(Integer) + entry_id: Mapped[str | None] = mapped_column(String(255), index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="RESTRICT"), nullable=False, index=True) + file_version_id: Mapped[str] = mapped_column(ForeignKey("file_versions.id", ondelete="RESTRICT"), nullable=False, index=True) + file_blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) + filename_used: Mapped[str] = mapped_column(String(500), nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + use_stage: Mapped[str] = mapped_column(String(20), default="built", nullable=False, index=True) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + +__all__ = [ + "CampaignAttachmentUse", + "FileAsset", + "FileBlob", + "FileFolder", + "FileShare", + "FileVersion", +] diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py new file mode 100644 index 0000000..d39bdaa --- /dev/null +++ b/src/govoplan_files/backend/manifest.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate +from govoplan_core.db.base import Base +from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Files", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission("files:file:read", "View files", "List, search and preview managed files."), + _permission("files:file:download", "Download files", "Download managed files and generated archives."), + _permission("files:file:upload", "Upload files", "Upload new managed file versions."), + _permission("files:file:organize", "Organize files", "Create folders, rename, move or copy managed files."), + _permission("files:file:share", "Share files", "Grant or revoke managed file shares."), + _permission("files:file:delete", "Delete files", "Delete or hide managed files and folders where policy allows it."), + _permission("files:file:admin", "Administer file spaces", "Administer all file spaces in the tenant."), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="file_manager", + name="File manager", + description="Manage tenant file spaces without campaign delivery rights.", + permissions=( + "files:file:read", + "files:file:download", + "files:file:upload", + "files:file:organize", + "files:file:share", + "files:file:delete", + ), + ), + RoleTemplate( + slug="file_viewer", + name="File viewer", + description="Read and download permitted files.", + permissions=("files:file:read", "files:file:download"), + ), +) + + +def _files_router(context: ModuleContext): + from govoplan_files.backend.runtime import configure_runtime + + configure_runtime(settings=context.settings) + from govoplan_files.backend.router import router + + return router + + +manifest = ModuleManifest( + id="files", + name="Files", + version="1.0.0", + dependencies=("access",), + optional_dependencies=(), + permissions=PERMISSIONS, + route_factory=_files_router, + role_templates=ROLE_TEMPLATES, + nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), + frontend=FrontendModule( + module_id="files", + package_name="@govoplan/files-webui", + nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), + ), + migration_spec=MigrationSpec( + module_id="files", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + diff --git a/src/govoplan_files/backend/migrations/README.md b/src/govoplan_files/backend/migrations/README.md new file mode 100644 index 0000000..3e67a93 --- /dev/null +++ b/src/govoplan_files/backend/migrations/README.md @@ -0,0 +1,3 @@ +# Files migrations + +New Files-owned database migrations belong in `versions/` and are registered through the Files module manifest. Historical migrations still live in the legacy application until those tables are fully split from the old `multi-seal-mail` repository. diff --git a/src/govoplan_files/backend/migrations/__init__.py b/src/govoplan_files/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_files/backend/migrations/versions/__init__.py b/src/govoplan_files/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py new file mode 100644 index 0000000..88aa807 --- /dev/null +++ b/src/govoplan_files/backend/router.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import json +import os +import tempfile +from io import BytesIO +from typing import Literal +from urllib.parse import quote +from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status +from fastapi.responses import FileResponse, StreamingResponse +from starlette.background import BackgroundTask +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope +from govoplan_files.backend.schemas import ( + ArchiveRequest, + BulkDeleteRequest, + BulkDeleteResponse, + ConflictResolutionRequest, + FileAssetResponse, + FileFolderCreateRequest, + FileFolderDeleteRequest, + FileFolderDeleteResponse, + FileFolderResponse, + FileFoldersResponse, + FileListResponse, + FileShareRequest, + FileShareResponse, + FileSpaceResponse, + FileSpacesResponse, + FileUploadResponse, + PatternMatchResponse, + PatternResolveRequest, + PatternResolveResponse, + RenamePreviewItem, + RenameRequest, + RenameResponse, + TransferRequest, + TransferResponse, + _conflict_resolutions, +) +from govoplan_core.db.models import Group, UserGroupMembership +from govoplan_campaign.backend.db.models import Campaign, CampaignShare +from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare +from govoplan_core.db.session import get_session +from govoplan_files.backend.runtime import settings +from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path +from govoplan_files.backend.storage.access import ensure_group_access, user_group_ids +from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload +from govoplan_files.backend.storage.common import FileStorageError +from govoplan_files.backend.storage.files import ( + asset_is_audit_relevant, + create_file_asset, + current_version_and_blob, + get_asset_for_user, + list_assets_for_user, + read_asset_bytes, + share_file, + soft_delete_assets, +) +from govoplan_files.backend.storage.folders import create_folder, list_folders_for_user, soft_delete_folder +from govoplan_files.backend.storage.search import resolve_patterns +from govoplan_files.backend.storage.transfers import rename_selection, transfer_selection + +router = APIRouter(prefix="/files", tags=["files"]) + + + +def _is_admin(principal: ApiPrincipal) -> bool: + return has_scope(principal, "files:file:admin") + + +async def _read_limited_upload(upload: UploadFile, *, max_bytes: int) -> bytes: + data = await upload.read(max_bytes + 1) + if len(data) > max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Upload exceeds limit of {max_bytes} bytes", + ) + return data + + + + +def _cleanup_temp_file(path: str) -> None: + try: + os.unlink(path) + except FileNotFoundError: + pass + + +def _attachment_disposition(filename: str) -> str: + safe = filename.replace("\\", "_").replace("/", "_").replace("\r", "_").replace("\n", "_").strip() or "download" + ascii_name = "".join( + char if 32 <= ord(char) < 127 and char not in {'"', "\\", ";"} else "_" + for char in safe + ).strip() or "download" + encoded = quote(safe, safe="") + return f'attachment; filename="{ascii_name}"; filename*=UTF-8' + "''" + encoded + + +def _ensure_campaign_file_access(session: Session, principal: ApiPrincipal, campaign_id: str | None) -> None: + if not campaign_id: + return + if not has_scope(principal, "campaigns:campaign:read"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: campaign:read") + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != principal.tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") + if has_scope(principal, "tenant:*"): + return + if campaign.owner_user_id == principal.user.id: + return + group_ids = { + row[0] + for row in session.query(UserGroupMembership.group_id) + .filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id) + .all() + } + if campaign.owner_group_id and campaign.owner_group_id in group_ids: + return + share = ( + session.query(CampaignShare) + .filter( + CampaignShare.tenant_id == principal.tenant_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.revoked_at.is_(None), + or_( + (CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.user.id), + (CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(group_ids)), + ), + ) + .first() + ) + if share is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign") + + +def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException: + code = status.HTTP_404_NOT_FOUND if not_found else status.HTTP_400_BAD_REQUEST + return HTTPException(status_code=code, detail=str(exc)) + + +def _owner_id(asset: FileAsset) -> str: + return asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id # type: ignore[return-value] + + +def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool = False) -> FileAssetResponse: + version, blob = current_version_and_blob(session, asset) + shares: list[FileShareResponse] = [] + if include_shares: + rows = session.query(FileShare).filter(FileShare.file_asset_id == asset.id).order_by(FileShare.created_at.desc()).all() + shares = [ + FileShareResponse( + id=row.id, + target_type=row.target_type, + target_id=row.target_id, + permission=row.permission, + created_at=row.created_at.isoformat(), + revoked_at=row.revoked_at.isoformat() if row.revoked_at else None, + ) + for row in rows + ] + return FileAssetResponse( + id=asset.id, + tenant_id=asset.tenant_id, + owner_type=asset.owner_type, + owner_id=_owner_id(asset), + display_path=asset.display_path, + filename=asset.filename, + description=asset.description, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + checksum_sha256=blob.checksum_sha256, + version_id=version.id, + created_at=asset.created_at.isoformat(), + updated_at=asset.updated_at.isoformat(), + deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None, + audit_relevant=asset_is_audit_relevant(session, asset), + metadata=asset.metadata_ or {}, + shares=shares, + ) + + +def _folder_owner_id(folder: FileFolder) -> str: + return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value] + + +def _folder_response(folder: FileFolder) -> FileFolderResponse: + return FileFolderResponse( + id=folder.id, + tenant_id=folder.tenant_id, + owner_type=folder.owner_type, + owner_id=_folder_owner_id(folder), + path=folder.path, + created_at=folder.created_at.isoformat(), + updated_at=folder.updated_at.isoformat(), + deleted_at=folder.deleted_at.isoformat() if folder.deleted_at else None, + ) + + +def _ensure_list_owner_access(session: Session, principal: ApiPrincipal, owner_type: str | None, owner_id: str | None) -> None: + if not owner_type: + return + if owner_type == "user" and owner_id and owner_id != principal.user.id and not _is_admin(principal): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this user file space") + if owner_type == "group" and owner_id: + try: + ensure_group_access( + session, + tenant_id=principal.tenant_id, + group_id=owner_id, + user_id=principal.user.id, + is_admin=_is_admin(principal), + ) + except FileStorageError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + + +@router.get("/spaces", response_model=FileSpacesResponse) +def list_file_spaces( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + spaces = [ + FileSpaceResponse( + id=f"user:{principal.user.id}", + label="My files", + owner_type="user", + owner_id=principal.user.id, + description="Files owned by your user account.", + ) + ] + group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal)) + if group_ids: + groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all() + spaces.extend( + FileSpaceResponse( + id=f"group:{group.id}", + label=f"{group.name} files", + owner_type="group", + owner_id=group.id, + description="Files owned by this group.", + ) + for group in groups + ) + return FileSpacesResponse(spaces=spaces) + + +@router.get("/folders", response_model=FileFoldersResponse) +def list_file_folders( + owner_type: Literal["user", "group"], + owner_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + try: + folders = list_folders_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=owner_type, + owner_id=owner_id, + is_admin=_is_admin(principal), + ) + return FileFoldersResponse(folders=[_folder_response(folder) for folder in folders]) + except FileStorageError as exc: + raise _http_error(exc) from exc + + +@router.post("/folders", response_model=FileFolderResponse) +def create_file_folder( + payload: FileFolderCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:organize")), +): + try: + folder = create_folder( + session, + tenant_id=principal.tenant_id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + user_id=principal.user.id, + path=payload.path, + is_admin=_is_admin(principal), + ) + session.commit() + return _folder_response(folder) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/folders/delete", response_model=FileFolderDeleteResponse) +def delete_file_folder( + payload: FileFolderDeleteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:delete")), +): + try: + deleted_folders, deleted_files = soft_delete_folder( + session, + tenant_id=principal.tenant_id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + user_id=principal.user.id, + path=payload.path, + recursive=payload.recursive, + is_admin=_is_admin(principal), + ) + session.commit() + return FileFolderDeleteResponse(deleted_folders=deleted_folders, deleted_files=deleted_files) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.get("", response_model=FileListResponse) +def list_files( + owner_type: Literal["user", "group"] | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + _ensure_list_owner_access(session, principal, owner_type, owner_id) + _ensure_campaign_file_access(session, principal, campaign_id) + assets = list_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + is_admin=_is_admin(principal), + ) + return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets]) + + +@router.post("/upload", response_model=FileUploadResponse) +async def upload_files( + files: list[UploadFile] = FastAPIFile(...), + owner_type: Literal["user", "group"] = Form(default="user"), + owner_id: str | None = Form(default=None), + path: str = Form(default=""), + campaign_id: str | None = Form(default=None), + unpack_zip: bool = Form(default=False), + conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(default="reject"), + conflict_resolutions_json: str | None = Form(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:upload")), +): + target_owner = owner_id or principal.user.id + uploaded_assets: list[FileAsset] = [] + try: + raw_resolutions = json.loads(conflict_resolutions_json) if conflict_resolutions_json else [] + upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions]) + for upload in files: + filename = upload.filename or "file" + content_type = upload.content_type or None + upload_limit = settings.file_upload_zip_max_bytes if unpack_zip and filename.lower().endswith(".zip") else settings.file_upload_max_bytes + data = await _read_limited_upload(upload, max_bytes=upload_limit) + if unpack_zip and filename.lower().endswith(".zip"): + extracted = extract_zip_upload( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + zip_data=data, + folder=path, + campaign_id=campaign_id, + conflict_strategy=conflict_strategy, + conflict_resolutions=upload_resolutions, + is_admin=_is_admin(principal), + max_file_bytes=settings.file_upload_max_bytes, + max_total_bytes=settings.file_upload_zip_max_bytes, + ) + uploaded_assets.extend(item.asset for item in extracted) + continue + stored = create_file_asset( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + filename=filename, + data=data, + folder=path, + content_type=content_type, + campaign_id=campaign_id, + conflict_strategy=conflict_strategy, + conflict_resolutions=upload_resolutions, + is_admin=_is_admin(principal), + ) + uploaded_assets.append(stored.asset) + session.commit() + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + return FileUploadResponse(files=[_asset_response(session, asset, include_shares=True) for asset in uploaded_assets]) + + +@router.post("/upload-zip", response_model=FileUploadResponse) +async def upload_zip( + file: UploadFile = FastAPIFile(...), + owner_type: Literal["user", "group"] = Form(default="user"), + owner_id: str | None = Form(default=None), + path: str = Form(default=""), + campaign_id: str | None = Form(default=None), + conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(default="reject"), + conflict_resolutions_json: str | None = Form(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:upload")), +): + data = await _read_limited_upload(file, max_bytes=settings.file_upload_zip_max_bytes) + target_owner = owner_id or principal.user.id + try: + raw_resolutions = json.loads(conflict_resolutions_json) if conflict_resolutions_json else [] + upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions]) + extracted = extract_zip_upload( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + zip_data=data, + folder=path, + campaign_id=campaign_id, + conflict_strategy=conflict_strategy, + conflict_resolutions=upload_resolutions, + is_admin=_is_admin(principal), + max_file_bytes=settings.file_upload_max_bytes, + max_total_bytes=settings.file_upload_zip_max_bytes, + ) + session.commit() + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + return FileUploadResponse(files=[_asset_response(session, item.asset, include_shares=True) for item in extracted]) + + +@router.get("/{file_id}", response_model=FileAssetResponse) +def get_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + return _asset_response(session, asset, include_shares=True) + except FileStorageError as exc: + raise _http_error(exc, not_found=True) from exc + + +@router.get("/{file_id}/download") +def download_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:download")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + data, _, blob = read_asset_bytes(session, asset) + except FileStorageError as exc: + raise _http_error(exc, not_found=True) from exc + headers = {"Content-Disposition": _attachment_disposition(asset.filename)} + return StreamingResponse(BytesIO(data), media_type=blob.content_type or "application/octet-stream", headers=headers) + + +@router.delete("/{file_id}", response_model=BulkDeleteResponse) +def delete_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:delete")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + count = soft_delete_assets(session, [asset]) + session.commit() + return BulkDeleteResponse(deleted_count=count) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc, not_found=True) from exc + + +@router.post("/bulk-delete", response_model=BulkDeleteResponse) +def bulk_delete_files( + payload: BulkDeleteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:delete")), +): + try: + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + for file_id in payload.file_ids + ] + count = soft_delete_assets(session, assets) + session.commit() + return BulkDeleteResponse(deleted_count=count) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/{file_id}/shares", response_model=FileShareResponse) +def create_share( + file_id: str, + payload: FileShareRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:share")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + share = share_file( + session, + tenant_id=principal.tenant_id, + asset=asset, + target_type=payload.target_type, + target_id=payload.target_id, + permission=payload.permission, + user_id=principal.user.id, + ) + session.commit() + return FileShareResponse( + id=share.id, + target_type=share.target_type, + target_id=share.target_id, + permission=share.permission, + created_at=share.created_at.isoformat(), + revoked_at=share.revoked_at.isoformat() if share.revoked_at else None, + ) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/bulk-rename", response_model=RenameResponse) +def bulk_rename( + payload: RenameRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:organize")), +): + try: + plan = rename_selection( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + file_ids=payload.file_ids, + folder_paths=payload.folder_paths, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + mode=payload.mode, + new_name=payload.new_name, + find=payload.find, + replacement=payload.replacement, + prefix=payload.prefix, + suffix=payload.suffix, + recursive=payload.recursive, + dry_run=payload.dry_run, + is_admin=_is_admin(principal), + ) + if not payload.dry_run: + session.commit() + return RenameResponse( + dry_run=payload.dry_run, + items=[ + RenamePreviewItem( + kind=item.kind, + id=item.id, + file_id=item.id if item.kind == "file" else None, + folder_path=item.old_path if item.kind == "folder" else None, + old_path=item.old_path, + new_path=item.new_path, + ) + for item in plan + ], + ) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/transfer", response_model=TransferResponse) +def transfer_files( + payload: TransferRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:organize")), +): + try: + files, folders = transfer_selection( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + operation=payload.operation, + file_ids=payload.file_ids, + folder_paths=payload.folder_paths, + source_owner_type=payload.source_owner_type, + source_owner_id=payload.source_owner_id, + target_owner_type=payload.target_owner_type, + target_owner_id=payload.target_owner_id, + target_folder=payload.target_folder, + conflict_strategy=payload.conflict_strategy, + conflict_resolutions=_conflict_resolutions(payload.conflict_resolutions), + is_admin=_is_admin(principal), + ) + session.commit() + return TransferResponse(operation=payload.operation, files=files, folders=folders) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/archive.zip") +def download_archive( + payload: ArchiveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:download")), +): + try: + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + for file_id in payload.file_ids + ] + tmp = tempfile.NamedTemporaryFile(prefix="multimailer-files-", suffix=".zip", delete=False) + tmp_path = tmp.name + tmp.close() + try: + create_zip_file(session, assets, tmp_path) + except Exception: + _cleanup_temp_file(tmp_path) + raise + except FileStorageError as exc: + raise _http_error(exc) from exc + filename = filename_from_path(normalize_logical_path(payload.filename, fallback_filename="files.zip")) + headers = {"Content-Disposition": _attachment_disposition(filename)} + return FileResponse(tmp_path, media_type="application/zip", headers=headers, background=BackgroundTask(_cleanup_temp_file, tmp_path)) + + +@router.post("/resolve-patterns", response_model=PatternResolveResponse) +def resolve_file_patterns( + payload: PatternResolveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + _ensure_list_owner_access(session, principal, payload.owner_type, payload.owner_id) + _ensure_campaign_file_access(session, principal, payload.campaign_id) + try: + assets = list_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + campaign_id=payload.campaign_id, + path_prefix=payload.path_prefix, + is_admin=_is_admin(principal), + ) + resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix, case_sensitive=payload.case_sensitive) + return PatternResolveResponse( + patterns=[PatternMatchResponse(pattern=item.pattern, matches=[_asset_response(session, asset) for asset in item.matches]) for item in resolved], + unmatched=[_asset_response(session, asset) for asset in unmatched] if payload.include_unmatched else [], + ) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + raise _http_error(exc) from exc diff --git a/src/govoplan_files/backend/runtime.py b/src/govoplan_files/backend/runtime.py new file mode 100644 index 0000000..1978213 --- /dev/null +++ b/src/govoplan_files/backend/runtime.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any + +_runtime_settings: object | None = None + + +def configure_runtime(*, settings: object | None = None) -> None: + global _runtime_settings + if settings is not None: + _runtime_settings = settings + + +def get_settings() -> object: + if _runtime_settings is not None: + return _runtime_settings + try: + from govoplan_core.settings import settings as legacy_settings + except ModuleNotFoundError as exc: + raise RuntimeError("GovOPlaN Files runtime settings are not configured") from exc + return legacy_settings + + +class SettingsProxy: + def __getattr__(self, name: str) -> Any: + return getattr(get_settings(), name) + + +settings = SettingsProxy() diff --git a/src/govoplan_files/backend/schemas.py b/src/govoplan_files/backend/schemas.py new file mode 100644 index 0000000..cb532e4 --- /dev/null +++ b/src/govoplan_files/backend/schemas.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from govoplan_files.backend.storage.common import FileConflictResolution + + +class FileSpaceResponse(BaseModel): + id: str + label: str + owner_type: Literal["user", "group"] + owner_id: str + description: str | None = None + + +class FileSpacesResponse(BaseModel): + spaces: list[FileSpaceResponse] + + +class FileShareResponse(BaseModel): + id: str + target_type: str + target_id: str + permission: str + created_at: str + revoked_at: str | None = None + + +class FileAssetResponse(BaseModel): + id: str + tenant_id: str + owner_type: str + owner_id: str + display_path: str + filename: str + description: str | None = None + size_bytes: int + content_type: str | None = None + checksum_sha256: str + version_id: str + created_at: str + updated_at: str + deleted_at: str | None = None + audit_relevant: bool = False + metadata: dict[str, Any] | None = None + shares: list[FileShareResponse] = Field(default_factory=list) + + +class FileFolderResponse(BaseModel): + id: str + tenant_id: str + owner_type: str + owner_id: str + path: str + created_at: str + updated_at: str + deleted_at: str | None = None + + +class FileFoldersResponse(BaseModel): + folders: list[FileFolderResponse] + + +class FileFolderCreateRequest(BaseModel): + owner_type: Literal["user", "group"] + owner_id: str + path: str + + +class FileFolderDeleteRequest(BaseModel): + owner_type: Literal["user", "group"] + owner_id: str + path: str + recursive: bool = True + + +class FileFolderDeleteResponse(BaseModel): + deleted_folders: int + deleted_files: int + + +class FileListResponse(BaseModel): + files: list[FileAssetResponse] + + +class FileUploadResponse(BaseModel): + files: list[FileAssetResponse] + + +class BulkDeleteRequest(BaseModel): + file_ids: list[str] + + +class BulkDeleteResponse(BaseModel): + deleted_count: int + + +class ConflictResolutionRequest(BaseModel): + target_path: str + action: Literal["overwrite", "rename", "skip"] + new_path: str | None = None + + +def _conflict_resolutions(items: list[ConflictResolutionRequest] | None) -> list[FileConflictResolution]: + return [FileConflictResolution(target_path=item.target_path, action=item.action, new_path=item.new_path) for item in items or []] + + +class FileShareRequest(BaseModel): + target_type: Literal["user", "group", "campaign", "tenant"] + target_id: str + permission: Literal["read", "write", "manage"] = "read" + + +class RenameRequest(BaseModel): + file_ids: list[str] = Field(default_factory=list) + folder_paths: list[str] = Field(default_factory=list) + owner_type: Literal["user", "group"] | None = None + owner_id: str | None = None + mode: Literal["direct", "prefix", "suffix", "replace"] + new_name: str | None = None + find: str | None = None + replacement: str = "" + prefix: str = "" + suffix: str = "" + recursive: bool = False + dry_run: bool = True + + +class RenamePreviewItem(BaseModel): + kind: Literal["file", "folder"] + id: str + file_id: str | None = None + folder_path: str | None = None + old_path: str + new_path: str + + +class RenameResponse(BaseModel): + dry_run: bool + items: list[RenamePreviewItem] + + +class TransferRequest(BaseModel): + operation: Literal["move", "copy"] + file_ids: list[str] = Field(default_factory=list) + folder_paths: list[str] = Field(default_factory=list) + source_owner_type: Literal["user", "group"] + source_owner_id: str + target_owner_type: Literal["user", "group"] + target_owner_id: str + target_folder: str = "" + conflict_strategy: Literal["reject", "overwrite", "rename"] = "reject" + conflict_resolutions: list[ConflictResolutionRequest] = Field(default_factory=list) + + +class TransferResponse(BaseModel): + operation: str + files: int + folders: int + + +class ArchiveRequest(BaseModel): + file_ids: list[str] + filename: str = "files.zip" + + +class PatternResolveRequest(BaseModel): + patterns: list[str] + owner_type: Literal["user", "group"] | None = None + owner_id: str | None = None + campaign_id: str | None = None + path_prefix: str | None = None + include_unmatched: bool = True + case_sensitive: bool = False + + +class PatternMatchResponse(BaseModel): + pattern: str + matches: list[FileAssetResponse] + + +class PatternResolveResponse(BaseModel): + patterns: list[PatternMatchResponse] + unmatched: list[FileAssetResponse] = Field(default_factory=list) diff --git a/src/govoplan_files/backend/storage/__init__.py b/src/govoplan_files/backend/storage/__init__.py new file mode 100644 index 0000000..15a8c8a --- /dev/null +++ b/src/govoplan_files/backend/storage/__init__.py @@ -0,0 +1 @@ +"""Managed file storage services for GovOPlaN Files.""" diff --git a/src/govoplan_files/backend/storage/access.py b/src/govoplan_files/backend/storage/access.py new file mode 100644 index 0000000..b720771 --- /dev/null +++ b/src/govoplan_files/backend/storage/access.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_core.db.models import Group, UserGroupMembership +from govoplan_files.backend.storage.common import FileStorageError + + +def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]: + if include_admin_groups: + return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()] + return [ + row.group_id + for row in session.query(UserGroupMembership) + .filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id) + .all() + ] + + +def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None: + group = session.get(Group, group_id) + if not group or group.tenant_id != tenant_id: + raise FileStorageError("Group not found") + if is_admin: + return + membership = ( + session.query(UserGroupMembership) + .filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id) + .one_or_none() + ) + if membership is None: + raise FileStorageError("No access to this group file space") + + +def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, user_id: str, is_admin: bool = False) -> None: + owner_type = owner_type.lower().strip() + if owner_type == "user": + if owner_id != user_id and not is_admin: + raise FileStorageError("No access to this user file space") + return + if owner_type == "group": + ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin) + return + raise FileStorageError("Files must be owned by a user or group") diff --git a/src/govoplan_files/backend/storage/archives.py b/src/govoplan_files/backend/storage/archives.py new file mode 100644 index 0000000..f838515 --- /dev/null +++ b/src/govoplan_files/backend/storage/archives.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import mimetypes +import zipfile +from io import BytesIO +from pathlib import Path +from typing import Iterable + +from sqlalchemy.orm import Session + +from govoplan_files.backend.db.models import FileAsset +from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile +from govoplan_files.backend.storage.backends import get_storage_backend +from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob +from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path + + +_ZIP_READ_CHUNK_SIZE = 1024 * 1024 + + +def _read_zip_member( + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, + *, + max_file_bytes: int, + max_total_bytes: int, + current_total: int, +) -> tuple[bytes, int]: + parts: list[bytes] = [] + actual_size = 0 + with archive.open(info) as source: + while True: + read_size = min(_ZIP_READ_CHUNK_SIZE, max_file_bytes + 1 - actual_size) + chunk = source.read(read_size) + if not chunk: + break + actual_size += len(chunk) + if actual_size > max_file_bytes: + raise FileStorageError(f"ZIP member {info.filename!r} exceeds per-file limit") + if current_total + actual_size > max_total_bytes: + raise FileStorageError("ZIP is too large after extraction") + parts.append(chunk) + return b"".join(parts), current_total + actual_size + + +def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None: + backend = get_storage_backend() + with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + for asset in assets: + _version, blob = current_version_and_blob(session, asset) + info = zipfile.ZipInfo(asset.display_path) + info.compress_type = zipfile.ZIP_DEFLATED + with archive.open(info, "w") as member: + for chunk in backend.iter_bytes(blob.storage_key): + if chunk: + member.write(chunk) + + +def extract_zip_upload( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + zip_data: bytes, + folder: str | None, + campaign_id: str | None, + conflict_strategy: str = "reject", + conflict_resolutions: Iterable[FileConflictResolution] | None = None, + is_admin: bool = False, + max_files: int = 1000, + max_file_bytes: int = 50 * 1024 * 1024, + max_total_bytes: int = 250 * 1024 * 1024, +) -> list[UploadedStoredFile]: + uploaded: list[UploadedStoredFile] = [] + total = 0 + base_folder = normalize_folder(folder) + try: + with zipfile.ZipFile(BytesIO(zip_data)) as archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) > max_files: + raise FileStorageError(f"ZIP contains too many files (limit {max_files})") + for info in infos: + if info.flag_bits & 0x1: + raise FileStorageError("Encrypted ZIP uploads are not supported") + if info.file_size < 0: + raise FileStorageError("Invalid ZIP member") + if info.file_size > max_file_bytes: + raise FileStorageError(f"ZIP member {info.filename!r} exceeds per-file limit") + if total + info.file_size > max_total_bytes: + raise FileStorageError("ZIP is too large after extraction") + inner_path = normalize_logical_path(info.filename) + target_path = f"{base_folder}/{inner_path}" if base_folder else inner_path + data, total = _read_zip_member( + archive, + info, + max_file_bytes=max_file_bytes, + max_total_bytes=max_total_bytes, + current_total=total, + ) + uploaded.append( + create_file_asset( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + user_id=user_id, + filename=filename_from_path(inner_path), + data=data, + display_path=target_path, + content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream", + campaign_id=campaign_id, + conflict_strategy=conflict_strategy, + conflict_resolutions=conflict_resolutions, + is_admin=is_admin, + ) + ) + except zipfile.BadZipFile as exc: + raise FileStorageError("Invalid ZIP upload") from exc + return uploaded diff --git a/src/govoplan_files/backend/storage/backends.py b/src/govoplan_files/backend/storage/backends.py new file mode 100644 index 0000000..1f8c638 --- /dev/null +++ b/src/govoplan_files/backend/storage/backends.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Protocol + +import boto3 + +from govoplan_files.backend.runtime import settings + + +class StorageBackendError(RuntimeError): + pass + + +class StorageBackend(Protocol): + name: str + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: ... + def get_bytes(self, key: str) -> bytes: ... + def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: ... + def delete(self, key: str) -> None: ... + def exists(self, key: str) -> bool: ... + + +@dataclass(slots=True) +class LocalFilesystemStorageBackend: + root: Path + name: str = "local" + + def __post_init__(self) -> None: + self.root = self.root.expanduser().resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> Path: + path = (self.root / key).resolve() + if not path.is_relative_to(self.root): + raise StorageBackendError("Storage key escapes local storage root") + return path + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: + path = self._path(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def get_bytes(self, key: str) -> bytes: + path = self._path(key) + if not path.exists() or not path.is_file(): + raise StorageBackendError("Stored object does not exist") + return path.read_bytes() + + def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: + path = self._path(key) + if not path.exists() or not path.is_file(): + raise StorageBackendError("Stored object does not exist") + with path.open("rb") as handle: + while True: + chunk = handle.read(chunk_size) + if not chunk: + break + yield chunk + + def delete(self, key: str) -> None: + path = self._path(key) + if path.exists() and path.is_file(): + path.unlink() + + def exists(self, key: str) -> bool: + path = self._path(key) + return path.exists() and path.is_file() + + +@dataclass(slots=True) +class S3StorageBackend: + bucket: str + endpoint_url: str + region_name: str + access_key_id: str + secret_access_key: str + name: str = "s3" + + @property + def client(self): + return boto3.client( + "s3", + endpoint_url=self.endpoint_url, + region_name=self.region_name, + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + ) + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: + kwargs = {"Bucket": self.bucket, "Key": key, "Body": data} + if content_type: + kwargs["ContentType"] = content_type + self.client.put_object(**kwargs) + + def get_bytes(self, key: str) -> bytes: + try: + obj = self.client.get_object(Bucket=self.bucket, Key=key) + return obj["Body"].read() + except Exception as exc: # pragma: no cover - depends on S3 backend + raise StorageBackendError(str(exc)) from exc + + def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: + try: + obj = self.client.get_object(Bucket=self.bucket, Key=key) + body = obj["Body"] + while True: + chunk = body.read(chunk_size) + if not chunk: + break + yield chunk + except Exception as exc: # pragma: no cover - depends on S3 backend + raise StorageBackendError(str(exc)) from exc + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.bucket, Key=key) + + def exists(self, key: str) -> bool: + try: + self.client.head_object(Bucket=self.bucket, Key=key) + return True + except Exception: + return False + + +def get_storage_backend() -> StorageBackend: + backend = settings.file_storage_backend.lower().strip() + if backend in {"local", "filesystem", "fs"}: + return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root)) + if backend in {"s3", "garage"}: + return S3StorageBackend( + bucket=settings.file_storage_s3_bucket or settings.s3_bucket, + endpoint_url=settings.file_storage_s3_endpoint_url or settings.s3_endpoint_url, + region_name=settings.file_storage_s3_region or settings.s3_region, + access_key_id=settings.file_storage_s3_access_key_id or settings.s3_access_key_id, + secret_access_key=settings.file_storage_s3_secret_access_key or settings.s3_secret_access_key, + ) + raise StorageBackendError(f"Unsupported file storage backend: {settings.file_storage_backend}") diff --git a/src/govoplan_files/backend/storage/campaign_attachments.py b/src/govoplan_files/backend/storage/campaign_attachments.py new file mode 100644 index 0000000..7e8adbe --- /dev/null +++ b/src/govoplan_files/backend/storage/campaign_attachments.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import copy +import json +import shutil +import tempfile +from collections import defaultdict +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Iterator + +from sqlalchemy.orm import Session + +from govoplan_files.backend.db.models import FileAsset +from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user, read_asset_bytes +from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component + + +MANAGED_SOURCE_PREFIX = "managed:" + + +@dataclass(frozen=True, slots=True) +class ManagedAttachmentFile: + local_path: str + asset_id: str + version_id: str + blob_id: str + display_path: str + relative_path: str + filename: str + owner_type: str + owner_id: str + checksum_sha256: str + size_bytes: int + content_type: str | None + + def as_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload.pop("local_path", None) + return payload + + +@dataclass(slots=True) +class PreparedCampaignSnapshot: + path: Path + raw_json: dict[str, Any] + managed_files_by_local_path: dict[str, ManagedAttachmentFile] + shared_assets: list[FileAsset] + + +def parse_managed_source(value: object) -> tuple[str, str] | None: + if not isinstance(value, str) or not value.startswith(MANAGED_SOURCE_PREFIX): + return None + parts = value.split(":", 2) + if len(parts) != 3 or parts[1] not in {"user", "group"} or not parts[2].strip(): + return None + return parts[1], parts[2].strip() + + +def _asset_owner_id(asset: FileAsset) -> str | None: + if asset.owner_type == "user": + return asset.owner_user_id + if asset.owner_type == "group": + return asset.owner_group_id + return None + + +def _relative_asset_path(asset: FileAsset, logical_root: str) -> str | None: + display_path = normalize_logical_path(asset.display_path) + root = normalize_folder(logical_root) + if not root: + return display_path + prefix = f"{root}/" + if not display_path.startswith(prefix): + return None + return display_path[len(prefix) :] + + +def _safe_local_target(root: Path, relative_path: str) -> Path: + parts = PurePosixPath(normalize_logical_path(relative_path)).parts + if not parts or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"Unsafe managed attachment path: {relative_path!r}") + target = root.joinpath(*parts).resolve() + resolved_root = root.resolve() + if not target.is_relative_to(resolved_root): + raise ValueError(f"Managed attachment path escapes materialization root: {relative_path!r}") + return target + + +def _iter_rule_dicts(attachments: dict[str, Any], raw_json: dict[str, Any]): + global_rules = attachments.get("global") + if isinstance(global_rules, list): + for rule in global_rules: + if isinstance(rule, dict): + yield rule + + entries = raw_json.get("entries") + inline = entries.get("inline") if isinstance(entries, dict) else None + if isinstance(inline, list): + for entry in inline: + if not isinstance(entry, dict): + continue + rules = entry.get("attachments") + if isinstance(rules, list): + for rule in rules: + if isinstance(rule, dict): + yield rule + + +def _selected_base_path( + rule: dict[str, Any], + prepared_by_id: dict[str, tuple[str, str]], + prepared_by_old_path: dict[str, list[tuple[str, str]]], + first_prepared: tuple[str, str] | None, +) -> tuple[str, str] | None: + base_path_id = str(rule.get("base_path_id") or "").strip() + if base_path_id and base_path_id in prepared_by_id: + return prepared_by_id[base_path_id] + + base_dir = str(rule.get("base_dir") or ".").strip() or "." + candidates = prepared_by_old_path.get(base_dir) + if candidates: + return candidates[0] + if base_dir in {"", "."}: + return first_prepared + return None + + +def prepare_campaign_snapshot( + session: Session, + *, + tenant_id: str, + campaign_id: str, + raw_json: dict[str, Any], + destination: Path, + include_bytes: bool, +) -> PreparedCampaignSnapshot: + """Create a temporary file-oriented campaign snapshot for managed attachments. + + The existing mailer resolver deliberately remains file-oriented. Managed + campaign-shared file versions are materialized into an isolated tree and a + copied campaign JSON is rewritten to point to those directories. The + returned manifest preserves exact asset/version/blob identity so build and + audit code never has to guess by filename. + """ + + destination = destination.expanduser().resolve() + destination.mkdir(parents=True, exist_ok=True) + materialized_root = destination / "managed-attachments" + materialized_root.mkdir(parents=True, exist_ok=True) + + prepared_json = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {}) + attachments = prepared_json.get("attachments") + if not isinstance(attachments, dict): + attachments = {} + prepared_json["attachments"] = attachments + base_paths = attachments.get("base_paths") + if not isinstance(base_paths, list): + base_paths = [] + + shared_assets = list_assets_for_user( + session, + tenant_id=tenant_id, + user_id="", + campaign_id=campaign_id, + is_admin=True, + ) + + assets_by_owner: dict[tuple[str, str], list[FileAsset]] = defaultdict(list) + for asset in shared_assets: + owner_id = _asset_owner_id(asset) + if owner_id: + assets_by_owner[(asset.owner_type, owner_id)].append(asset) + + manifest: dict[str, ManagedAttachmentFile] = {} + prepared_by_id: dict[str, tuple[str, str]] = {} + prepared_by_old_path: dict[str, list[tuple[str, str]]] = {} + first_prepared: tuple[str, str] | None = None + + for index, item in enumerate(base_paths): + if not isinstance(item, dict): + continue + parsed_source = parse_managed_source(item.get("source")) + if parsed_source is None: + continue + owner_type, owner_id = parsed_source + old_path = str(item.get("path") or ".").strip() or "." + logical_root = "" if old_path in {"", ".", "/"} else normalize_folder(old_path) + base_path_id = str(item.get("id") or f"base-path-{index + 1}") + local_root = materialized_root / f"{index + 1:03d}-{safe_storage_component(base_path_id)}" + local_root.mkdir(parents=True, exist_ok=True) + local_root_string = str(local_root.resolve()) + + prepared = (base_path_id, local_root_string) + prepared_by_id[base_path_id] = prepared + prepared_by_old_path.setdefault(old_path, []).append(prepared) + if first_prepared is None: + first_prepared = prepared + + item["path"] = local_root_string + + for asset in assets_by_owner.get((owner_type, owner_id), []): + relative_path = _relative_asset_path(asset, logical_root) + if not relative_path: + continue + target = _safe_local_target(local_root, relative_path) + target.parent.mkdir(parents=True, exist_ok=True) + if include_bytes: + data, version, blob = read_asset_bytes(session, asset) + target.write_bytes(data) + else: + version, blob = current_version_and_blob(session, asset) + target.touch() + local_key = str(target.resolve()) + manifest[local_key] = ManagedAttachmentFile( + local_path=local_key, + asset_id=asset.id, + version_id=version.id, + blob_id=blob.id, + display_path=asset.display_path, + relative_path=normalize_logical_path(relative_path), + filename=asset.filename, + owner_type=asset.owner_type, + owner_id=owner_id, + checksum_sha256=blob.checksum_sha256, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + ) + + for rule in _iter_rule_dicts(attachments, prepared_json): + selected = _selected_base_path(rule, prepared_by_id, prepared_by_old_path, first_prepared) + if selected is None: + continue + base_path_id, local_root_string = selected + rule["base_path_id"] = base_path_id + rule["base_dir"] = local_root_string + + if first_prepared is not None: + attachments["base_path"] = first_prepared[1] + + snapshot_path = destination / "campaign.json" + snapshot_path.write_text(json.dumps(prepared_json, ensure_ascii=False, indent=2), encoding="utf-8") + return PreparedCampaignSnapshot( + path=snapshot_path, + raw_json=prepared_json, + managed_files_by_local_path=manifest, + shared_assets=shared_assets, + ) + + +@contextmanager +def prepared_campaign_snapshot( + session: Session, + *, + tenant_id: str, + campaign_id: str, + raw_json: dict[str, Any], + include_bytes: bool, + prefix: str = "multimailer-managed-campaign-", +) -> Iterator[PreparedCampaignSnapshot]: + temp_dir = Path(tempfile.mkdtemp(prefix=prefix)) + try: + yield prepare_campaign_snapshot( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + raw_json=raw_json, + destination=temp_dir, + include_bytes=include_bytes, + ) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def managed_match_payloads( + matches: list[str], + manifest: dict[str, ManagedAttachmentFile], +) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + for match in matches: + item = manifest.get(str(Path(match).resolve())) + if item is not None: + payloads.append(item.as_dict()) + return payloads + + +def public_attachment_summary_payload(value: Any) -> dict[str, Any]: + """Return an attachment summary without temporary materialization paths. + + Managed builds use isolated local directories internally. Queue, review and + audit payloads must expose the stable managed paths and immutable IDs + instead of those temporary paths. Legacy filesystem attachments remain + unchanged for backwards compatibility. + """ + + if hasattr(value, "model_dump"): + payload = value.model_dump(mode="json") + elif isinstance(value, dict): + payload = copy.deepcopy(value) + else: + return {} + + managed_matches = payload.get("managed_matches") + if not isinstance(managed_matches, list) or not managed_matches: + return payload + + logical_matches: list[str] = [] + for item in managed_matches: + if not isinstance(item, dict): + continue + display_path = str(item.get("display_path") or item.get("relative_path") or item.get("filename") or "").strip() + if display_path: + logical_matches.append(display_path) + + payload["matches"] = logical_matches + # These values point into a deleted temporary materialization directory. + # The named source plus managed match metadata are the stable references. + payload["base_path"] = None + payload["directory"] = payload.get("base_path_name") or "managed" + return payload + + +def annotate_built_messages_with_managed_files( + built_messages: list[Any], + manifest: dict[str, ManagedAttachmentFile], +) -> None: + """Attach exact managed-file identities to built attachment summaries.""" + + for built in built_messages: + draft = getattr(built, "draft", None) + for attachment in getattr(draft, "attachments", []) if draft is not None else []: + matches = list(getattr(attachment, "matches", []) or []) + attachment.managed_matches = managed_match_payloads(matches, manifest) diff --git a/src/govoplan_files/backend/storage/campaign_usage.py b/src/govoplan_files/backend/storage/campaign_usage.py new file mode 100644 index 0000000..547b83e --- /dev/null +++ b/src/govoplan_files/backend/storage/campaign_usage.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from collections import defaultdict +from pathlib import PurePosixPath +from typing import Iterable + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import CampaignJob +from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion +from govoplan_files.backend.storage.common import utcnow +from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user + + +def _candidate_match_keys(raw_match: str) -> set[str]: + cleaned = raw_match.replace("\\", "/").strip().strip("/") + result = {cleaned} + if cleaned: + result.add(PurePosixPath(cleaned).name) + return {item for item in result if item} + + +AttachmentUseKey = tuple[str, str, str, str] + + +def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str, stage: str) -> AttachmentUseKey: + return job_id, file_version_id, filename_used, stage + + +def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]: + job_ids = {job.id for job in jobs if job.id} + if not job_ids: + return set() + + keys = { + _attachment_use_key( + job_id=job_id, + file_version_id=file_version_id, + filename_used=filename_used, + stage=use_stage, + ) + for job_id, file_version_id, filename_used, use_stage in ( + session.query( + CampaignAttachmentUse.campaign_job_id, + CampaignAttachmentUse.file_version_id, + CampaignAttachmentUse.filename_used, + CampaignAttachmentUse.use_stage, + ) + .filter( + CampaignAttachmentUse.campaign_job_id.in_(job_ids), + CampaignAttachmentUse.use_stage == stage, + ) + .all() + ) + if job_id is not None + } + for pending in session.new: + if not isinstance(pending, CampaignAttachmentUse): + continue + if pending.campaign_job_id not in job_ids or pending.use_stage != stage: + continue + keys.add( + _attachment_use_key( + job_id=pending.campaign_job_id, + file_version_id=pending.file_version_id, + filename_used=pending.filename_used, + stage=pending.use_stage, + ) + ) + return keys + + +def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]: + return _known_use_keys_for_jobs(session, [job], stage=stage) + + +def _add_use( + session: Session, + job: CampaignJob, + *, + asset: FileAsset, + version: FileVersion, + blob: FileBlob, + filename_used: str, + stage: str, + known_keys: set[AttachmentUseKey], +) -> None: + key = _attachment_use_key( + job_id=job.id, + file_version_id=version.id, + filename_used=filename_used, + stage=stage, + ) + if key in known_keys: + return + known_keys.add(key) + session.add( + CampaignAttachmentUse( + tenant_id=job.tenant_id, + campaign_id=job.campaign_id, + campaign_version_id=job.campaign_version_id, + campaign_job_id=job.id, + entry_index=job.entry_index, + entry_id=job.entry_id, + file_asset_id=asset.id, + file_version_id=version.id, + file_blob_id=blob.id, + filename_used=filename_used, + checksum_sha256=blob.checksum_sha256, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + use_stage=stage, + ) + ) + + +def record_campaign_attachment_uses_for_jobs( + session: Session, + jobs: Iterable[CampaignJob], + *, + stage: str = "built", +) -> None: + """Record immutable attachment evidence for multiple jobs efficiently. + + Build operations may create thousands of jobs in one transaction. Loading + every campaign-shared asset and flushing once per recipient turns that into + an avoidable query/flush multiplier. This batch path resolves the exact + managed IDs once, fetches each referenced table in bulk, and reuses legacy + filename maps per campaign only when an older snapshot needs them. + """ + + job_list = [job for job in jobs if job.id] + if not job_list: + return + + managed_by_job: dict[str, list[dict[str, object]]] = defaultdict(list) + fallback_attachments_by_job: dict[str, list[dict[str, object]]] = defaultdict(list) + job_by_id = {job.id: job for job in job_list} + asset_ids: set[str] = set() + version_ids: set[str] = set() + blob_ids: set[str] = set() + + for job in job_list: + attachments = job.resolved_attachments or [] + if not isinstance(attachments, list): + continue + for attachment in attachments: + if not isinstance(attachment, dict): + continue + managed_matches = attachment.get("managed_matches") + if isinstance(managed_matches, list) and managed_matches: + for item in managed_matches: + if not isinstance(item, dict): + continue + asset_id = str(item.get("asset_id") or "") + version_id = str(item.get("version_id") or "") + blob_id = str(item.get("blob_id") or "") + if not asset_id or not version_id or not blob_id: + continue + managed_by_job[job.id].append(item) + asset_ids.add(asset_id) + version_ids.add(version_id) + blob_ids.add(blob_id) + else: + fallback_attachments_by_job[job.id].append(attachment) + + assets_by_id = { + item.id: item + for item in session.query(FileAsset).filter(FileAsset.id.in_(asset_ids)).all() + } if asset_ids else {} + versions_by_id = { + item.id: item + for item in session.query(FileVersion).filter(FileVersion.id.in_(version_ids)).all() + } if version_ids else {} + blobs_by_id = { + item.id: item + for item in session.query(FileBlob).filter(FileBlob.id.in_(blob_ids)).all() + } if blob_ids else {} + known_keys = _known_use_keys_for_jobs(session, job_list, stage=stage) + + for job_id, items in managed_by_job.items(): + job = job_by_id[job_id] + for item in items: + asset = assets_by_id.get(str(item.get("asset_id") or "")) + version = versions_by_id.get(str(item.get("version_id") or "")) + blob = blobs_by_id.get(str(item.get("blob_id") or "")) + if not asset or not version or not blob: + continue + if asset.tenant_id != job.tenant_id or version.tenant_id != job.tenant_id or blob.tenant_id != job.tenant_id: + continue + if version.file_asset_id != asset.id or version.blob_id != blob.id: + continue + _add_use( + session, + job, + asset=asset, + version=version, + blob=blob, + filename_used=str(item.get("filename") or asset.filename), + stage=stage, + known_keys=known_keys, + ) + + assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {} + for job_id, attachments in fallback_attachments_by_job.items(): + job = job_by_id[job_id] + campaign_key = (job.tenant_id, job.campaign_id) + by_key = assets_by_campaign.get(campaign_key) + if by_key is None: + assets = list_assets_for_user( + session, + tenant_id=job.tenant_id, + user_id="", + campaign_id=job.campaign_id, + is_admin=True, + ) + by_key = {} + for asset in assets: + by_key[asset.display_path.strip("/")] = asset + by_key.setdefault(asset.filename, asset) + assets_by_campaign[campaign_key] = by_key + + for attachment in attachments: + matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else [] + for raw in matches: + if not isinstance(raw, str): + continue + asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None) + if not asset: + continue + version, blob = current_version_and_blob(session, asset) + _add_use( + session, + job, + asset=asset, + version=version, + blob=blob, + filename_used=asset.filename, + stage=stage, + known_keys=known_keys, + ) + + +def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None: + """Record immutable managed file versions used by one built/sent job.""" + + record_campaign_attachment_uses_for_jobs(session, [job], stage=stage) + + +def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None: + record_campaign_attachment_uses_for_job(session, job, stage="built") + # Sessions use autoflush=False. Flush any compatibility-built evidence so + # the following query can copy it to the sent stage in the same call. + session.flush() + now = utcnow() + uses = ( + session.query(CampaignAttachmentUse) + .filter( + CampaignAttachmentUse.tenant_id == job.tenant_id, + CampaignAttachmentUse.campaign_job_id == job.id, + CampaignAttachmentUse.use_stage == "built", + ) + .all() + ) + sent_keys = _known_use_keys(session, job, stage="sent") + for use in uses: + key = _attachment_use_key( + job_id=job.id, + file_version_id=use.file_version_id, + filename_used=use.filename_used, + stage="sent", + ) + if key in sent_keys: + continue + sent_keys.add(key) + session.add( + CampaignAttachmentUse( + tenant_id=use.tenant_id, + campaign_id=use.campaign_id, + campaign_version_id=use.campaign_version_id, + campaign_job_id=use.campaign_job_id, + entry_index=use.entry_index, + entry_id=use.entry_id, + file_asset_id=use.file_asset_id, + file_version_id=use.file_version_id, + file_blob_id=use.file_blob_id, + filename_used=use.filename_used, + checksum_sha256=use.checksum_sha256, + size_bytes=use.size_bytes, + content_type=use.content_type, + use_stage="sent", + used_at=now, + ) + ) diff --git a/src/govoplan_files/backend/storage/common.py b/src/govoplan_files/backend/storage/common.py new file mode 100644 index 0000000..3c096bc --- /dev/null +++ b/src/govoplan_files/backend/storage/common.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from govoplan_files.backend.db.models import FileAsset, FileBlob, FileVersion + + +class FileStorageError(RuntimeError): + pass + + +@dataclass(slots=True) +class UploadedStoredFile: + asset: FileAsset + version: FileVersion + blob: FileBlob + + +@dataclass(slots=True) +class ResolvedPattern: + pattern: str + matches: list[FileAsset] + + +@dataclass(slots=True) +class FileConflictResolution: + target_path: str + action: str + new_path: str | None = None + + +@dataclass(slots=True) +class RenamePlanItem: + kind: str + id: str + old_path: str + new_path: str + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py new file mode 100644 index 0000000..8ef32a0 --- /dev/null +++ b/src/govoplan_files/backend/storage/files.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import hashlib +import mimetypes +from pathlib import PurePosixPath +from typing import Any, Iterable +from uuid import uuid4 + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.db.models import Group, Tenant, User +from govoplan_campaign.backend.db.models import Campaign +from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion +from govoplan_files.backend.runtime import settings +from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids +from govoplan_files.backend.storage.backends import get_storage_backend +from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow +from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component + + +def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): + query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_type == owner_type) + if owner_type == "user": + return query.filter(FileAsset.owner_user_id == owner_id) + if owner_type == "group": + return query.filter(FileAsset.owner_group_id == owner_id) + raise FileStorageError("Unsupported owner type") + + +def _storage_bucket_name() -> str: + return settings.file_storage_s3_bucket or settings.s3_bucket + + +def _storage_backend_name() -> str: + return settings.file_storage_backend.lower().strip() + + +def _storage_key(*, tenant_id: str, checksum: str, filename: str) -> str: + return f"tenants/{tenant_id}/files/{checksum[:2]}/{uuid4().hex}-{safe_storage_component(filename)}" + + +def _get_or_create_blob( + session: Session, + *, + tenant_id: str, + data: bytes, + filename: str, + content_type: str | None, +) -> FileBlob: + checksum = hashlib.sha256(data).hexdigest() + size = len(data) + blob = ( + session.query(FileBlob) + .filter(FileBlob.tenant_id == tenant_id, FileBlob.checksum_sha256 == checksum, FileBlob.size_bytes == size) + .one_or_none() + ) + if blob: + blob.ref_count += 1 + session.add(blob) + return blob + + storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum, filename=filename) + backend = get_storage_backend() + backend.put_bytes(storage_key, data, content_type=content_type) + blob = FileBlob( + tenant_id=tenant_id, + storage_backend=_storage_backend_name(), + storage_bucket=_storage_bucket_name(), + storage_key=storage_key, + checksum_sha256=checksum, + size_bytes=size, + content_type=content_type, + ref_count=1, + ) + session.add(blob) + session.flush() + return blob + + +def create_file_asset( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + filename: str, + data: bytes, + folder: str | None = None, + display_path: str | None = None, + content_type: str | None = None, + description: str | None = None, + metadata: dict[str, Any] | None = None, + campaign_id: str | None = None, + conflict_strategy: str = "reject", + conflict_resolutions: Iterable[FileConflictResolution] | None = None, + is_admin: bool = False, +) -> UploadedStoredFile: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + + safe_filename = filename_from_path(normalize_logical_path(filename, fallback_filename="file")) + logical_path = normalize_logical_path(display_path) if display_path else join_folder_filename(folder, safe_filename) + if not content_type: + content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream" + + conflict_strategy = _normalize_conflict_strategy(conflict_strategy) + resolutions = _resolution_by_path(conflict_resolutions) + resolution = resolutions.get(logical_path) + action = resolution.action if resolution else conflict_strategy + if resolution and resolution.new_path and action == "rename": + logical_path = normalize_logical_path(resolution.new_path) + elif _active_asset_exists(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, path=logical_path): + if action == "reject": + raise FileStorageError(f"Target file already exists: {logical_path}") + if action == "overwrite": + _soft_delete_conflicting_asset(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, path=logical_path) + elif action == "rename": + logical_path = _next_available_logical_path(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, desired_path=logical_path) + elif action == "skip": + raise FileStorageError(f"Skipped upload target: {logical_path}") + elif action == "rename": + logical_path = _next_available_logical_path(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, desired_path=logical_path) + + blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type) + asset = FileAsset( + tenant_id=tenant_id, + owner_type=owner_type, + owner_user_id=owner_id if owner_type == "user" else None, + owner_group_id=owner_id if owner_type == "group" else None, + display_path=logical_path, + filename=filename_from_path(logical_path), + description=description, + created_by_user_id=user_id, + metadata_=metadata or {}, + ) + session.add(asset) + session.flush() + version = FileVersion( + tenant_id=tenant_id, + file_asset_id=asset.id, + blob_id=blob.id, + version_number=1, + filename_at_upload=safe_filename, + display_path_at_upload=logical_path, + content_type=content_type, + size_bytes=blob.size_bytes, + checksum_sha256=blob.checksum_sha256, + created_by_user_id=user_id, + ) + session.add(version) + session.flush() + asset.current_version_id = version.id + session.add(asset) + if campaign_id: + share_file(session, tenant_id=tenant_id, asset=asset, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id) + return UploadedStoredFile(asset=asset, version=version, blob=blob) + + +def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset: + asset = session.get(FileAsset, asset_id) + if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None: + raise FileStorageError("File not found") + if is_admin: + return asset + group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id) + owns = (asset.owner_type == "user" and asset.owner_user_id == user_id) or (asset.owner_type == "group" and asset.owner_group_id in group_ids) + if owns: + return asset + permission_values = ["read", "write", "manage"] if not require_write else ["write", "manage"] + share = ( + session.query(FileShare) + .filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id == asset.id, + FileShare.revoked_at.is_(None), + FileShare.permission.in_(permission_values), + or_( + (FileShare.target_type == "user") & (FileShare.target_id == user_id), + (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)), + (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id), + ), + ) + .first() + ) + if not share: + raise FileStorageError("No access to this file") + return asset + + +def list_assets_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + owner_type: str | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + include_deleted: bool = False, + is_admin: bool = False, +) -> list[FileAsset]: + query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id) + if not include_deleted: + query = query.filter(FileAsset.deleted_at.is_(None)) + if owner_type: + query = query.filter(FileAsset.owner_type == owner_type) + if owner_type == "user" and owner_id: + query = query.filter(FileAsset.owner_user_id == owner_id) + if owner_type == "group" and owner_id: + query = query.filter(FileAsset.owner_group_id == owner_id) + if campaign_id: + query = query.join(FileShare, FileShare.file_asset_id == FileAsset.id).filter( + FileShare.tenant_id == tenant_id, + FileShare.target_type == "campaign", + FileShare.target_id == campaign_id, + FileShare.revoked_at.is_(None), + ) + elif not is_admin and not owner_type: + group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id) + query = query.outerjoin(FileShare, FileShare.file_asset_id == FileAsset.id).filter( + or_( + (FileAsset.owner_type == "user") & (FileAsset.owner_user_id == user_id), + (FileAsset.owner_type == "group") & (FileAsset.owner_group_id.in_(group_ids)), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "user") & (FileShare.target_id == user_id), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id), + ) + ) + if path_prefix: + prefix = normalize_folder(path_prefix) + if prefix: + query = query.filter(FileAsset.display_path.like(f"{prefix}/%")) + return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc()).all() + + +def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]: + if not asset.current_version_id: + raise FileStorageError("File has no current version") + version = session.get(FileVersion, asset.current_version_id) + if not version: + raise FileStorageError("File version not found") + blob = session.get(FileBlob, version.blob_id) + if not blob: + raise FileStorageError("File blob not found") + return version, blob + + +def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: + version, blob = current_version_and_blob(session, asset) + backend = get_storage_backend() + return backend.get_bytes(blob.storage_key), version, blob + + +def share_file( + session: Session, + *, + tenant_id: str, + asset: FileAsset, + target_type: str, + target_id: str, + permission: str, + user_id: str, +) -> FileShare: + target_type = target_type.lower().strip() + permission = permission.lower().strip() + if asset.tenant_id != tenant_id: + raise FileStorageError("File not found") + if target_type not in {"user", "group", "campaign", "tenant"}: + raise FileStorageError("Unsupported share target") + if permission not in {"read", "write", "manage"}: + raise FileStorageError("Unsupported file permission") + if target_type == "user": + target_user = session.get(User, target_id) + if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active: + raise FileStorageError("User not found") + if target_type == "group": + group = session.get(Group, target_id) + if not group or group.tenant_id != tenant_id or not group.is_active: + raise FileStorageError("Group not found") + if target_type == "tenant": + tenant = session.get(Tenant, target_id) + if target_id != tenant_id or not tenant or not tenant.is_active: + raise FileStorageError("Tenant not found") + if target_type == "campaign": + campaign = session.get(Campaign, target_id) + if not campaign or campaign.tenant_id != tenant_id: + raise FileStorageError("Campaign not found") + existing = ( + session.query(FileShare) + .filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id == asset.id, + FileShare.target_type == target_type, + FileShare.target_id == target_id, + FileShare.revoked_at.is_(None), + ) + .one_or_none() + ) + if existing: + existing.permission = permission + session.add(existing) + return existing + share = FileShare( + tenant_id=tenant_id, + file_asset_id=asset.id, + target_type=target_type, + target_id=target_id, + permission=permission, + created_by_user_id=user_id, + ) + session.add(share) + return share + + +def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int: + count = 0 + now = utcnow() + for asset in assets: + if asset.deleted_at is None: + asset.deleted_at = now + session.add(asset) + count += 1 + return count + + +def asset_is_audit_relevant(session: Session, asset: FileAsset) -> bool: + return ( + session.query(CampaignAttachmentUse) + .filter(CampaignAttachmentUse.file_asset_id == asset.id, CampaignAttachmentUse.use_stage == "sent") + .first() + is not None + ) + + +def _asset_owner_id(asset: FileAsset) -> str: + if asset.owner_type == "user" and asset.owner_user_id: + return asset.owner_user_id + if asset.owner_type == "group" and asset.owner_group_id: + return asset.owner_group_id + raise FileStorageError("File has no valid owner") + + +def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool: + return _active_asset_at_path( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + path=path, + exclude_asset_id=exclude_asset_id, + ) is not None + + +def _active_asset_at_path(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> FileAsset | None: + query = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter( + FileAsset.deleted_at.is_(None), + FileAsset.display_path == normalize_logical_path(path), + ) + if exclude_asset_id: + query = query.filter(FileAsset.id != exclude_asset_id) + return query.first() + + +def _soft_delete_conflicting_asset(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> None: + asset = _active_asset_at_path( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + path=path, + exclude_asset_id=exclude_asset_id, + ) + if asset is not None: + asset.deleted_at = utcnow() + session.add(asset) + + +def _split_logical_path(path: str) -> tuple[str, str, str, str]: + normalized = normalize_logical_path(path) + logical = PurePosixPath(normalized) + folder = "" if str(logical.parent) == "." else str(logical.parent) + filename = logical.name + suffixes = "".join(PurePosixPath(filename).suffixes) + stem = filename[: -len(suffixes)] if suffixes else filename + return folder, filename, stem, suffixes + + +def _candidate_renamed_path(path: str, counter: int) -> str: + folder, _filename, stem, suffixes = _split_logical_path(path) + suffix = " copy" if counter == 1 else f" copy {counter}" + next_name = f"{stem}{suffix}{suffixes}" + return normalize_logical_path(f"{folder}/{next_name}" if folder else next_name) + + +def _next_available_logical_path( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + desired_path: str, + reserved_paths: set[str] | None = None, + exclude_asset_id: str | None = None, +) -> str: + reserved = reserved_paths or set() + desired = normalize_logical_path(desired_path) + if desired not in reserved and not _active_asset_exists( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + path=desired, + exclude_asset_id=exclude_asset_id, + ): + return desired + counter = 1 + while True: + candidate = _candidate_renamed_path(desired, counter) + if candidate not in reserved and not _active_asset_exists( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + path=candidate, + exclude_asset_id=exclude_asset_id, + ): + return candidate + counter += 1 + + +def _resolution_by_path(conflict_resolutions: Iterable[FileConflictResolution] | None) -> dict[str, FileConflictResolution]: + result: dict[str, FileConflictResolution] = {} + for item in conflict_resolutions or []: + target_path = normalize_logical_path(item.target_path) + action = item.action.lower().strip() + if action not in {"overwrite", "rename", "skip"}: + raise FileStorageError("Unsupported conflict resolution") + result[target_path] = FileConflictResolution(target_path=target_path, action=action, new_path=item.new_path) + return result + + +def _normalize_conflict_strategy(strategy: str | None) -> str: + normalized = (strategy or "reject").lower().strip() + if normalized not in {"reject", "overwrite", "rename"}: + raise FileStorageError("Unsupported conflict strategy") + return normalized + + +def _copy_asset_to_path( + session: Session, + asset: FileAsset, + *, + tenant_id: str, + target_owner_type: str, + target_owner_id: str, + target_path: str, + user_id: str, +) -> FileAsset: + version, blob = current_version_and_blob(session, asset) + blob.ref_count += 1 + session.add(blob) + normalized_path = normalize_logical_path(target_path) + copied = FileAsset( + tenant_id=tenant_id, + owner_type=target_owner_type, + owner_user_id=target_owner_id if target_owner_type == "user" else None, + owner_group_id=target_owner_id if target_owner_type == "group" else None, + display_path=normalized_path, + filename=filename_from_path(normalized_path), + description=asset.description, + created_by_user_id=user_id, + metadata_=dict(asset.metadata_ or {}), + ) + session.add(copied) + session.flush() + copied_version = FileVersion( + tenant_id=tenant_id, + file_asset_id=copied.id, + blob_id=blob.id, + version_number=1, + filename_at_upload=version.filename_at_upload, + display_path_at_upload=normalized_path, + content_type=version.content_type, + size_bytes=blob.size_bytes, + checksum_sha256=blob.checksum_sha256, + created_by_user_id=user_id, + ) + session.add(copied_version) + session.flush() + copied.current_version_id = copied_version.id + session.add(copied) + return copied + + +def rename_asset(asset: FileAsset, *, new_path: str) -> None: + normalized = normalize_logical_path(new_path) + asset.display_path = normalized + asset.filename = filename_from_path(normalized) diff --git a/src/govoplan_files/backend/storage/folders.py b/src/govoplan_files/backend/storage/folders.py new file mode 100644 index 0000000..fa91e84 --- /dev/null +++ b/src/govoplan_files/backend/storage/folders.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_files.backend.db.models import FileAsset, FileFolder +from govoplan_files.backend.storage.access import ensure_owner_access +from govoplan_files.backend.storage.common import FileStorageError, utcnow +from govoplan_files.backend.storage.files import _asset_query_for_owner +from govoplan_files.backend.storage.paths import normalize_folder + + +def _owner_filter(query, owner_type: str, owner_id: str): + if owner_type == "user": + return query.filter(FileFolder.owner_user_id == owner_id) + if owner_type == "group": + return query.filter(FileFolder.owner_group_id == owner_id) + raise FileStorageError("Unsupported owner type") + + +def create_folder( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + path: str, + is_admin: bool = False, +) -> FileFolder: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + normalized = normalize_folder(path) + if not normalized: + raise FileStorageError("Folder path is required") + query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type, FileFolder.path == normalized) + query = _owner_filter(query, owner_type, owner_id) + active_existing = query.filter(FileFolder.deleted_at.is_(None)).first() + if active_existing is not None: + raise FileStorageError(f"Folder already exists: {normalized}") + deleted_existing = query.filter(FileFolder.deleted_at.is_not(None)).first() + if deleted_existing is not None: + deleted_existing.deleted_at = None + session.add(deleted_existing) + session.flush() + return deleted_existing + folder = FileFolder( + tenant_id=tenant_id, + owner_type=owner_type, + owner_user_id=owner_id if owner_type == "user" else None, + owner_group_id=owner_id if owner_type == "group" else None, + path=normalized, + created_by_user_id=user_id, + metadata_={}, + ) + session.add(folder) + session.flush() + return folder + + +def list_folders_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + owner_type: str, + owner_id: str, + include_deleted: bool = False, + is_admin: bool = False, +) -> list[FileFolder]: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type) + query = _owner_filter(query, owner_type, owner_id) + if not include_deleted: + query = query.filter(FileFolder.deleted_at.is_(None)) + return query.order_by(FileFolder.path.asc()).all() + + +def soft_delete_folder( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + path: str, + recursive: bool = True, + is_admin: bool = False, +) -> tuple[int, int]: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + normalized = normalize_folder(path) + if not normalized: + raise FileStorageError("Folder path is required") + prefix = f"{normalized}/" + now = utcnow() + + folder_query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type, FileFolder.deleted_at.is_(None)) + folder_query = _owner_filter(folder_query, owner_type, owner_id) + if recursive: + folder_query = folder_query.filter(or_(FileFolder.path == normalized, FileFolder.path.like(f"{prefix}%"))) + else: + child_exists = folder_query.filter(FileFolder.path.like(f"{prefix}%")).first() is not None + file_exists = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(FileAsset.display_path.like(f"{prefix}%")).first() is not None + if child_exists or file_exists: + raise FileStorageError("Folder is not empty") + folder_query = folder_query.filter(FileFolder.path == normalized) + + folders = folder_query.all() + for folder in folders: + folder.deleted_at = now + session.add(folder) + + file_query = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{prefix}%")) + assets = file_query.all() if recursive else [] + for asset in assets: + asset.deleted_at = now + session.add(asset) + + return len(folders), len(assets) + + +def _active_folder_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_paths: set[str] | None = None) -> bool: + normalized = normalize_folder(path) + if not normalized: + return False + query = session.query(FileFolder).filter( + FileFolder.tenant_id == tenant_id, + FileFolder.owner_type == owner_type, + FileFolder.deleted_at.is_(None), + FileFolder.path == normalized, + ) + query = _owner_filter(query, owner_type, owner_id) + if exclude_paths: + query = query.filter(FileFolder.path.notin_(exclude_paths)) + return query.first() is not None + + +def _folder_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): + query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type) + return _owner_filter(query, owner_type, owner_id) + + +def _ensure_target_folder_hierarchy( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + path: str, +) -> None: + parts = normalize_folder(path).split("/") if normalize_folder(path) else [] + for index in range(1, len(parts) + 1): + partial = "/".join(parts[:index]) + query = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(FileFolder.path == partial) + matches = query.order_by(FileFolder.deleted_at.asc(), FileFolder.created_at.asc(), FileFolder.id.asc()).all() + existing = next((folder for folder in matches if folder.deleted_at is None), matches[0] if matches else None) + if existing: + # Historical databases may contain a soft-deleted row alongside an + # active row, or even multiple active rows from concurrent creates. + # Keep one canonical row and retire the rest instead of letting + # one_or_none() abort a move/copy operation. + if existing.deleted_at is not None: + existing.deleted_at = None + now = utcnow() + for duplicate in matches: + if duplicate.id != existing.id and duplicate.deleted_at is None: + duplicate.deleted_at = now + session.add(duplicate) + session.add(existing) + continue + folder = FileFolder( + tenant_id=tenant_id, + owner_type=owner_type, + owner_user_id=owner_id if owner_type == "user" else None, + owner_group_id=owner_id if owner_type == "group" else None, + path=partial, + created_by_user_id=user_id, + metadata_={}, + ) + session.add(folder) diff --git a/src/govoplan_files/backend/storage/paths.py b/src/govoplan_files/backend/storage/paths.py new file mode 100644 index 0000000..0c9c4e9 --- /dev/null +++ b/src/govoplan_files/backend/storage/paths.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import re +from pathlib import PurePosixPath +from uuid import uuid4 + +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.@ -]+") + + +class UnsafeFilePathError(ValueError): + pass + + +def normalize_logical_path(path: str | None, *, fallback_filename: str | None = None) -> str: + """Return a safe tenant-relative logical path using POSIX separators. + + The logical path is metadata, not a filesystem path. It never starts with a + slash and cannot contain path traversal components. It is used for browsing, + wildcard matching and attachment rules. + """ + + raw = (path or "").replace("\\", "/").strip() + if not raw and fallback_filename: + raw = fallback_filename + if not raw: + raise UnsafeFilePathError("File path is empty") + if raw.startswith("/"): + raw = raw.lstrip("/") + parts: list[str] = [] + for part in raw.split("/"): + clean = part.strip() + if not clean or clean == ".": + continue + if clean == "..": + raise UnsafeFilePathError("Path traversal is not allowed") + parts.append(clean) + if not parts: + raise UnsafeFilePathError("File path is empty") + return "/".join(parts) + + +def normalize_folder(path: str | None) -> str: + raw = (path or "").replace("\\", "/").strip().strip("/") + if not raw: + return "" + normalized = normalize_logical_path(raw) + return "" if normalized == "." else normalized + + +def filename_from_path(path: str) -> str: + name = PurePosixPath(path).name + if not name or name in {".", ".."}: + raise UnsafeFilePathError("Invalid filename") + return name + + +def join_folder_filename(folder: str | None, filename: str) -> str: + safe_name = sanitize_filename(filename) + safe_folder = normalize_folder(folder) + return f"{safe_folder}/{safe_name}" if safe_folder else safe_name + + +def sanitize_filename(filename: str | None) -> str: + raw = (filename or "file").replace("\\", "/").split("/")[-1].strip() + raw = raw.strip(".") or "file" + safe = _SAFE_NAME_RE.sub("_", raw) + safe = re.sub(r"\s+", " ", safe).strip() + return safe or f"file-{uuid4().hex}" + + +def safe_storage_component(value: str | None, fallback: str = "file") -> str: + safe = sanitize_filename(value or fallback) + return safe.replace(" ", "_")[:180] diff --git a/src/govoplan_files/backend/storage/search.py b/src/govoplan_files/backend/storage/search.py new file mode 100644 index 0000000..436cab7 --- /dev/null +++ b/src/govoplan_files/backend/storage/search.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import re +from typing import Iterable + +from govoplan_files.backend.db.models import FileAsset +from govoplan_files.backend.storage.common import ResolvedPattern +from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path + + +def _normalize_pattern(pattern: str) -> str: + if pattern.strip() in {"", "*"}: + return "*" + return normalize_logical_path(pattern, fallback_filename="*") + + +def _logical_glob_regex(pattern: str, *, case_sensitive: bool = False) -> re.Pattern[str]: + """Compile Multi Seal Mail logical globs. + + `*` and `?` stay within one folder segment. `**` crosses folder + boundaries, and `**/` also matches the current folder so `**/*.pdf` + returns direct and nested PDF files. + """ + + pattern = _normalize_pattern(pattern) + pieces = ["^"] + index = 0 + while index < len(pattern): + char = pattern[index] + if char == "*": + if index + 1 < len(pattern) and pattern[index + 1] == "*": + index += 2 + if index < len(pattern) and pattern[index] == "/": + pieces.append("(?:.*/)?") + index += 1 + else: + pieces.append(".*") + continue + pieces.append("[^/]*") + elif char == "?": + pieces.append("[^/]") + else: + pieces.append(re.escape(char)) + index += 1 + pieces.append("$") + flags = 0 if case_sensitive else re.IGNORECASE + return re.compile("".join(pieces), flags) + + +def _relative_display_path(asset: FileAsset, base_path: str | None) -> str: + path = normalize_logical_path(asset.display_path) + base = normalize_folder(base_path) + if not base: + return path + prefix = f"{base}/" + if path.startswith(prefix): + return path[len(prefix) :] + return path + + +def match_assets(assets: Iterable[FileAsset], pattern: str, *, base_path: str | None = None, case_sensitive: bool = False) -> list[FileAsset]: + regex = _logical_glob_regex(pattern, case_sensitive=case_sensitive) + normalized_pattern = _normalize_pattern(pattern) + has_path_context = base_path is not None or "/" in normalized_pattern or "**" in normalized_pattern + matches: list[FileAsset] = [] + for asset in assets: + candidates = [_relative_display_path(asset, base_path)] if has_path_context else [asset.display_path, asset.filename] + if any(regex.match(candidate) for candidate in candidates): + matches.append(asset) + return matches + + +def resolve_patterns(assets: list[FileAsset], patterns: list[str], *, base_path: str | None = None, case_sensitive: bool = False) -> tuple[list[ResolvedPattern], list[FileAsset]]: + resolved = [ResolvedPattern(pattern=pattern, matches=match_assets(assets, pattern, base_path=base_path, case_sensitive=case_sensitive)) for pattern in patterns] + matched_ids = {asset.id for item in resolved for asset in item.matches} + unmatched = [asset for asset in assets if asset.id not in matched_ids] + return resolved, unmatched diff --git a/src/govoplan_files/backend/storage/services.py b/src/govoplan_files/backend/storage/services.py new file mode 100644 index 0000000..1fdbe9e --- /dev/null +++ b/src/govoplan_files/backend/storage/services.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +# Compatibility facade for existing storage imports. New storage code should +# prefer importing from the focused modules directly. + +from govoplan_files.backend.storage.access import ensure_group_access, ensure_owner_access, user_group_ids +from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload +from govoplan_files.backend.storage.campaign_usage import mark_job_attachment_uses_sent, record_campaign_attachment_uses_for_job +from govoplan_files.backend.storage.common import ( + FileConflictResolution, + FileStorageError, + RenamePlanItem, + ResolvedPattern, + UploadedStoredFile, + utcnow, +) +from govoplan_files.backend.storage.files import ( + _active_asset_at_path, + _active_asset_exists, + _asset_owner_id, + _asset_query_for_owner, + _candidate_renamed_path, + _copy_asset_to_path, + _get_or_create_blob, + _next_available_logical_path, + _normalize_conflict_strategy, + _resolution_by_path, + _soft_delete_conflicting_asset, + _split_logical_path, + _storage_backend_name, + _storage_bucket_name, + _storage_key, + asset_is_audit_relevant, + create_file_asset, + current_version_and_blob, + get_asset_for_user, + list_assets_for_user, + read_asset_bytes, + rename_asset, + share_file, + soft_delete_assets, +) +from govoplan_files.backend.storage.folders import ( + _active_folder_exists, + _ensure_target_folder_hierarchy, + _folder_query_for_owner, + _owner_filter, + create_folder, + list_folders_for_user, + soft_delete_folder, +) +from govoplan_files.backend.storage.search import match_assets, resolve_patterns +from govoplan_files.backend.storage.transfers import build_rename_preview, rename_selection, transfer_selection + +__all__ = [ + "FileConflictResolution", + "FileStorageError", + "RenamePlanItem", + "ResolvedPattern", + "UploadedStoredFile", + "asset_is_audit_relevant", + "build_rename_preview", + "create_file_asset", + "create_folder", + "create_zip_file", + "current_version_and_blob", + "ensure_group_access", + "ensure_owner_access", + "extract_zip_upload", + "get_asset_for_user", + "list_assets_for_user", + "list_folders_for_user", + "mark_job_attachment_uses_sent", + "match_assets", + "read_asset_bytes", + "record_campaign_attachment_uses_for_job", + "rename_asset", + "rename_selection", + "resolve_patterns", + "share_file", + "soft_delete_assets", + "soft_delete_folder", + "transfer_selection", + "user_group_ids", + "utcnow", +] diff --git a/src/govoplan_files/backend/storage/transfers.py b/src/govoplan_files/backend/storage/transfers.py new file mode 100644 index 0000000..dad2d62 --- /dev/null +++ b/src/govoplan_files/backend/storage/transfers.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import Iterable + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_files.backend.db.models import FileAsset, FileFolder +from govoplan_files.backend.storage.access import ensure_owner_access +from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, RenamePlanItem, utcnow +from govoplan_files.backend.storage.files import ( + _active_asset_exists, + _asset_owner_id, + _asset_query_for_owner, + _candidate_renamed_path, + _copy_asset_to_path, + _next_available_logical_path, + _normalize_conflict_strategy, + _resolution_by_path, + _soft_delete_conflicting_asset, + get_asset_for_user, + rename_asset, +) +from govoplan_files.backend.storage.folders import _active_folder_exists, _ensure_target_folder_hierarchy, _folder_query_for_owner +from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path + + +def transfer_selection( + session: Session, + *, + tenant_id: str, + user_id: str, + operation: str, + file_ids: list[str], + folder_paths: list[str], + source_owner_type: str, + source_owner_id: str, + target_owner_type: str, + target_owner_id: str, + target_folder: str, + conflict_strategy: str = "reject", + conflict_resolutions: Iterable[FileConflictResolution] | None = None, + is_admin: bool = False, +) -> tuple[int, int]: + """Move or copy files/folders between user/group file spaces. + + Folder transfers preserve the selected folder's basename below the target + folder. File transfers place files directly in the target folder. Existing + active target paths are handled by the requested conflict strategy. Copies + create new file assets/versions that reference the existing immutable blob. + """ + + operation = operation.lower().strip() + if operation not in {"move", "copy"}: + raise FileStorageError("Unsupported transfer operation") + source_owner_type = source_owner_type.lower().strip() + target_owner_type = target_owner_type.lower().strip() + source_folder_paths = [normalize_folder(path) for path in folder_paths if normalize_folder(path)] + target_folder = normalize_folder(target_folder) + conflict_strategy = _normalize_conflict_strategy(conflict_strategy) + conflict_resolution_map = _resolution_by_path(conflict_resolutions) + + ensure_owner_access(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id, user_id=user_id, is_admin=is_admin) + ensure_owner_access(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, is_admin=is_admin) + + if operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id: + for folder_path in source_folder_paths: + if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"): + raise FileStorageError("Cannot move a folder into itself or one of its child folders") + + assets_by_id: dict[str, FileAsset] = {} + for file_id in file_ids: + asset = get_asset_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + asset_id=file_id, + require_write=operation == "move", + is_admin=is_admin, + ) + assets_by_id[asset.id] = asset + + folder_asset_targets: dict[str, str] = {} + folder_target_paths: dict[str, str] = {} + for folder_path in source_folder_paths: + folder_basename = PurePosixPath(folder_path).name + target_prefix = normalize_folder(f"{target_folder}/{folder_basename}" if target_folder else folder_basename) + folder_target_paths[folder_path] = target_prefix + if operation == "copy" or not (source_owner_type == target_owner_type and source_owner_id == target_owner_id and target_prefix == folder_path): + if _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=target_prefix): + resolution = conflict_resolution_map.get(target_prefix) + action = resolution.action if resolution else conflict_strategy + if action == "skip": + folder_target_paths.pop(folder_path, None) + continue + if action == "rename": + # Rename the selected folder root while preserving its contents below it. + counter = 1 + candidate = target_prefix + while _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=candidate): + candidate = _candidate_renamed_path(target_prefix, counter) + counter += 1 + target_prefix = normalize_folder(candidate) + folder_target_paths[folder_path] = target_prefix + elif action == "reject": + raise FileStorageError(f"Target folder already exists: {target_prefix}") + # overwrite on folders means merge into the existing folder; conflicting files are still handled below. + source_assets = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter( + FileAsset.deleted_at.is_(None), + FileAsset.display_path.like(f"{folder_path}/%"), + ).all() + for asset in source_assets: + relative = asset.display_path[len(folder_path) + 1 :] + folder_asset_targets[asset.id] = normalize_logical_path(f"{target_prefix}/{relative}") + assets_by_id[asset.id] = asset + + direct_file_targets: dict[str, str] = {} + for file_id, asset in assets_by_id.items(): + if file_id in folder_asset_targets: + continue + direct_file_targets[file_id] = normalize_logical_path(join_folder_filename(target_folder, filename_from_path(asset.display_path))) + + all_targets = {**folder_asset_targets, **direct_file_targets} + if not all_targets and not source_folder_paths: + return 0, 0 + + resolved_targets: dict[str, str] = {} + skipped_asset_ids: set[str] = set() + reserved_targets: set[str] = set() + for asset_id, target_path in all_targets.items(): + source_asset = assets_by_id[asset_id] + same_owner = (source_asset.owner_type == target_owner_type and _asset_owner_id(source_asset) == target_owner_id) + exclude = source_asset.id if operation == "move" and same_owner else None + normalized_target = normalize_logical_path(target_path) + resolution = conflict_resolution_map.get(normalized_target) + action = resolution.action if resolution else conflict_strategy + if resolution and resolution.new_path and action == "rename": + normalized_target = normalize_logical_path(resolution.new_path) + conflict = _active_asset_exists( + session, + tenant_id=tenant_id, + owner_type=target_owner_type, + owner_id=target_owner_id, + path=normalized_target, + exclude_asset_id=exclude, + ) or normalized_target in reserved_targets + if conflict: + if action == "reject": + raise FileStorageError(f"Target file already exists: {normalized_target}") + if action == "skip": + skipped_asset_ids.add(asset_id) + continue + if action == "overwrite": + _soft_delete_conflicting_asset( + session, + tenant_id=tenant_id, + owner_type=target_owner_type, + owner_id=target_owner_id, + path=normalized_target, + exclude_asset_id=exclude, + ) + elif action == "rename": + normalized_target = _next_available_logical_path( + session, + tenant_id=tenant_id, + owner_type=target_owner_type, + owner_id=target_owner_id, + desired_path=normalized_target, + reserved_paths=reserved_targets, + exclude_asset_id=exclude, + ) + elif action == "rename": + normalized_target = _next_available_logical_path( + session, + tenant_id=tenant_id, + owner_type=target_owner_type, + owner_id=target_owner_id, + desired_path=normalized_target, + reserved_paths=reserved_targets, + exclude_asset_id=exclude, + ) + reserved_targets.add(normalized_target) + resolved_targets[asset_id] = normalized_target + all_targets = resolved_targets + + copied_or_moved_files = 0 + copied_or_moved_folders = 0 + + # Create target folder hierarchy first, including selected folder roots and + # any nested folders/files parents. + same_owner_transfer = source_owner_type == target_owner_type and source_owner_id == target_owner_id + for target_path in folder_target_paths.values(): + path_to_create = target_path + if operation == "move" and same_owner_transfer: + parent = str(PurePosixPath(target_path).parent) + path_to_create = "" if parent == "." else parent + if path_to_create: + _ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=path_to_create) + copied_or_moved_folders += 1 + for target_path in all_targets.values(): + parent = str(PurePosixPath(target_path).parent) + if parent and parent != ".": + _ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=parent) + + if operation == "copy": + for asset_id, target_path in all_targets.items(): + _copy_asset_to_path(session, assets_by_id[asset_id], tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id) + copied_or_moved_files += 1 + return copied_or_moved_files, copied_or_moved_folders + + now = utcnow() + for asset_id, target_path in all_targets.items(): + asset = assets_by_id[asset_id] + same_owner = asset.owner_type == target_owner_type and _asset_owner_id(asset) == target_owner_id + if same_owner: + if normalize_logical_path(asset.display_path) == target_path: + continue + rename_asset(asset, new_path=target_path) + session.add(asset) + else: + _copy_asset_to_path(session, asset, tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id) + asset.deleted_at = now + session.add(asset) + copied_or_moved_files += 1 + + # Move/copy persisted folder records after files. For cross-owner moves, create + # target records and soft-delete source records. For same-owner moves, rename + # them in place. + for source_path, target_prefix in folder_target_paths.items(): + folder_rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter( + FileFolder.deleted_at.is_(None), + or_(FileFolder.path == source_path, FileFolder.path.like(f"{source_path}/%")), + ).all() + for folder in folder_rows: + relative = "" if folder.path == source_path else folder.path[len(source_path) + 1 :] + new_path = normalize_folder(f"{target_prefix}/{relative}" if relative else target_prefix) + if operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id: + folder.path = new_path + session.add(folder) + else: + _ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=new_path) + if operation == "move": + folder.deleted_at = now + session.add(folder) + return copied_or_moved_files, copied_or_moved_folders + + +def _rename_basename(name: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str: + if mode == "direct": + cleaned = normalize_logical_path(new_name or "", fallback_filename="item") + if "/" in cleaned: + raise FileStorageError("Rename expects a name, not a path") + return cleaned + stem_path = PurePosixPath(name) + suffixes = "".join(stem_path.suffixes) + stem = name[: -len(suffixes)] if suffixes else name + if mode == "prefix": + return prefix + name + if mode == "suffix": + return f"{stem}{suffix}{suffixes}" + if mode == "replace": + return name.replace(find or "", replacement) if find else name + raise FileStorageError("Unsupported rename mode") + + +def _rename_path(path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str: + normalized = normalize_logical_path(path) + logical = PurePosixPath(normalized) + folder = "" if str(logical.parent) == "." else str(logical.parent) + next_name = _rename_basename(logical.name, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + return normalize_logical_path(f"{folder}/{next_name}" if folder else next_name) + + +def _rename_relative_recursive(relative_path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str: + parts = normalize_logical_path(relative_path).split("/") + return normalize_logical_path( + "/".join( + _rename_basename(part, mode=mode, new_name=new_name if len(parts) == 1 else None, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + for part in parts + if part + ) + ) + + +def build_rename_preview(asset: FileAsset, *, mode: str, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str: + return _rename_path(asset.display_path, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + + +def _collapse_folder_roots(folder_paths: list[str]) -> list[str]: + roots = sorted({normalize_folder(path) for path in folder_paths if normalize_folder(path)}, key=lambda item: (item.count("/"), item)) + collapsed: list[str] = [] + for path in roots: + if any(path == root or path.startswith(f"{root}/") for root in collapsed): + continue + collapsed.append(path) + return collapsed + + +def _path_under_root(path: str, root: str) -> bool: + normalized = normalize_logical_path(path) + return normalized == root or normalized.startswith(f"{root}/") + + +def _folder_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str: + if path == root: + return normalize_folder(new_root) + relative = path[len(root) + 1 :] + if recursive: + relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + return normalize_folder(f"{new_root}/{relative}") + + +def _file_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str: + relative = path[len(root) + 1 :] + if recursive: + relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + return normalize_logical_path(f"{new_root}/{relative}") + + +def rename_selection( + session: Session, + *, + tenant_id: str, + user_id: str, + file_ids: list[str], + folder_paths: list[str], + owner_type: str | None, + owner_id: str | None, + mode: str, + new_name: str | None = None, + find: str | None = None, + replacement: str = "", + prefix: str = "", + suffix: str = "", + recursive: bool = False, + dry_run: bool = True, + is_admin: bool = False, +) -> list[RenamePlanItem]: + mode = mode.lower().strip() + if mode not in {"direct", "prefix", "suffix", "replace"}: + raise FileStorageError("Unsupported rename mode") + selected_file_ids = list(dict.fromkeys(file_ids)) + selected_folder_roots = _collapse_folder_roots(folder_paths) + if not selected_file_ids and not selected_folder_roots: + raise FileStorageError("No files or folders selected") + if selected_folder_roots and (not owner_type or not owner_id): + raise FileStorageError("Folder rename requires an owner file space") + if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1: + raise FileStorageError("Direct rename requires exactly one selected item") + + assets: dict[str, FileAsset] = {} + for file_id in selected_file_ids: + asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin) + assets[asset.id] = asset + + owner_type_norm = owner_type.lower().strip() if owner_type else None + owner_id_norm = owner_id + folder_rows_by_path: dict[str, FileFolder] = {} + affected_folder_paths: set[str] = set() + if selected_folder_roots and owner_type_norm and owner_id_norm: + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin) + for root in selected_folder_roots: + rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter( + FileFolder.deleted_at.is_(None), + or_(FileFolder.path == root, FileFolder.path.like(f"{root}/%")), + ).all() + if not rows and not _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).first(): + raise FileStorageError(f"Folder not found: {root}") + for row in rows: + folder_rows_by_path[row.path] = row + affected_folder_paths.add(row.path) + for asset in _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).all(): + assets[asset.id] = asset + + folder_plan: dict[str, str] = {} + root_target_paths: dict[str, str] = {} + for root in selected_folder_roots: + if mode == "direct": + root_parent = "" if str(PurePosixPath(root).parent) == "." else str(PurePosixPath(root).parent) + root_new_name = _rename_basename(PurePosixPath(root).name, mode=mode, new_name=new_name) + new_root = normalize_folder(f"{root_parent}/{root_new_name}" if root_parent else root_new_name) + else: + new_root = normalize_folder(_rename_path(root, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)) + root_target_paths[root] = new_root + if root in affected_folder_paths: + folder_plan[root] = new_root + for path in sorted(affected_folder_paths, key=lambda item: item.count("/")): + if path != root and _path_under_root(path, root): + folder_plan[path] = _folder_new_path_for_root(path, root, new_root, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + + file_plan: dict[str, str] = {} + for asset in assets.values(): + matched_root = next((root for root in sorted(selected_folder_roots, key=len, reverse=True) if _path_under_root(asset.display_path, root)), None) + if matched_root: + root_new = root_target_paths.get(matched_root) + if not root_new: + continue + file_plan[asset.id] = _file_new_path_for_root(asset.display_path, matched_root, root_new, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + else: + file_plan[asset.id] = _rename_path(asset.display_path, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix) + + # Detect duplicate targets and existing active target conflicts before applying. + target_files: set[str] = set() + target_folders: set[str] = set() + affected_file_ids = set(file_plan) + for asset_id, target in file_plan.items(): + normalized = normalize_logical_path(target) + if normalized in target_files: + raise FileStorageError(f"Rename would create duplicate file path: {normalized}") + target_files.add(normalized) + asset = assets[asset_id] + if _active_asset_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=_asset_owner_id(asset), path=normalized, exclude_asset_id=asset.id): + raise FileStorageError(f"Target file already exists: {normalized}") + if _active_folder_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=_asset_owner_id(asset), path=normalized): + raise FileStorageError(f"Target path is already a folder: {normalized}") + if selected_folder_roots and owner_type_norm and owner_id_norm: + for old_path, target in folder_plan.items(): + normalized = normalize_folder(target) + if normalized in target_folders: + raise FileStorageError(f"Rename would create duplicate folder path: {normalized}") + target_folders.add(normalized) + if _active_folder_exists(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, path=normalized, exclude_paths=set(folder_plan.keys())): + raise FileStorageError(f"Target folder already exists: {normalized}") + if _active_asset_exists(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, path=normalized): + raise FileStorageError(f"Target path is already a file: {normalized}") + + plan: list[RenamePlanItem] = [] + for old_path, new_path in sorted(folder_plan.items()): + plan.append(RenamePlanItem(kind="folder", id=old_path, old_path=old_path, new_path=new_path)) + for asset_id, new_path in file_plan.items(): + asset = assets[asset_id] + plan.append(RenamePlanItem(kind="file", id=asset.id, old_path=asset.display_path, new_path=new_path)) + + if dry_run: + return plan + + for old_path, new_path in folder_plan.items(): + folder = folder_rows_by_path.get(old_path) + if folder: + folder.path = normalize_folder(new_path) + session.add(folder) + for asset_id, new_path in file_plan.items(): + rename_asset(assets[asset_id], new_path=new_path) + session.add(assets[asset_id]) + return plan diff --git a/src/govoplan_files/py.typed b/src/govoplan_files/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..72e6a59 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@govoplan/files-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/file-manager.css": "./src/styles/file-manager.css" + }, + "peerDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.6", + "typescript": "^5.7.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "lucide-react": "^0.555.0", + "@govoplan/core-webui": "^0.1.0" + } +} diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts new file mode 100644 index 0000000..f856f81 --- /dev/null +++ b/webui/src/api/files.ts @@ -0,0 +1,201 @@ +import { apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; + +export type FileSpace = { + id: string; + label: string; + owner_type: "user" | "group"; + owner_id: string; + description?: string | null; +}; + +export type FileShare = { + id: string; + target_type: string; + target_id: string; + permission: string; + created_at: string; + revoked_at?: string | null; +}; + +export type ManagedFile = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + display_path: string; + filename: string; + description?: string | null; + size_bytes: number; + content_type?: string | null; + checksum_sha256: string; + version_id: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; + audit_relevant: boolean; + metadata?: Record | null; + shares?: FileShare[]; +}; + +export type FileListResponse = { files: ManagedFile[] }; +export type FileSpacesResponse = { spaces: FileSpace[] }; +export type FileUploadResponse = { files: ManagedFile[] }; +export type FileFolder = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + path: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; +}; +export type FileFoldersResponse = { folders: FileFolder[] }; +export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number }; +export type BulkDeleteResponse = { deleted_count: number }; +export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] }; +export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number }; +export type ConflictAction = "overwrite" | "rename" | "skip"; +export type ConflictStrategy = "reject" | "overwrite" | "rename"; +export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string }; +export type PatternResolveResponse = { + patterns: { pattern: string; matches: ManagedFile[] }[]; + unmatched: ManagedFile[]; +}; + + +export function listFileSpaces(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/files/spaces"); +} + + +export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise { + const search = new URLSearchParams(); + search.set("owner_type", params.owner_type); + search.set("owner_id", params.owner_id); + return apiFetch(settings, `/api/v1/files/folders?${search.toString()}`); +} + +export function createFolder( + settings: ApiSettings, + payload: { owner_type: "user" | "group"; owner_id: string; path: string } +): Promise { + return apiFetch(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) }); +} + +export function deleteFolder( + settings: ApiSettings, + payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); +} + +export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value); + } + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/files${suffix}`); +} + +export async function uploadFiles( + settings: ApiSettings, + files: File[], + options: { owner_type: "user" | "group"; owner_id: string; path?: string; campaign_id?: string; unpack_zip?: boolean; conflict_strategy?: ConflictStrategy; conflict_resolutions?: ConflictResolution[] } +): Promise { + const form = new FormData(); + files.forEach((file) => form.append("files", file)); + form.append("owner_type", options.owner_type); + form.append("owner_id", options.owner_id); + form.append("path", options.path ?? ""); + if (options.campaign_id) form.append("campaign_id", options.campaign_id); + if (options.unpack_zip) form.append("unpack_zip", "true"); + if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy); + if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions)); + return apiFetch(settings, "/api/v1/files/upload", { method: "POST", body: form }); +} + +export function deleteFile(settings: ApiSettings, fileId: string): Promise { + return apiFetch(settings, `/api/v1/files/${fileId}`, { method: "DELETE" }); +} + +export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promise { + return apiFetch(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) }); +} + +export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { + return apiFetch(settings, `/api/v1/files/${fileId}/shares`, { + method: "POST", + body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" }) + }); +} + +export function bulkRenameFiles( + settings: ApiSettings, + payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/bulk-rename", { + method: "POST", + body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload }) + }); +} + +export function resolveFilePatterns( + settings: ApiSettings, + payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) }); +} + +export function transferFiles( + settings: ApiSettings, + payload: { + operation: "move" | "copy"; + file_ids: string[]; + folder_paths: string[]; + source_owner_type: "user" | "group"; + source_owner_id: string; + target_owner_type: "user" | "group"; + target_owner_id: string; + target_folder: string; + conflict_strategy?: ConflictStrategy; + conflict_resolutions?: ConflictResolution[]; + } +): Promise { + return apiFetch(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) }); +} + +export async function downloadFile(settings: ApiSettings, file: ManagedFile): Promise { + const response = await fetch(apiUrl(settings, `/api/v1/files/${file.id}/download`), { headers: authHeaders(settings), credentials: "include" }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + const blob = await response.blob(); + triggerDownload(blob, file.filename); +} + +export async function downloadFilesAsZip(settings: ApiSettings, fileIds: string[], filename = "files.zip"): Promise { + const headers = authHeaders(settings); + headers.set("Content-Type", "application/json"); + const csrf = csrfToken(); + if (csrf) headers.set("X-CSRF-Token", csrf); + const response = await fetch(apiUrl(settings, "/api/v1/files/archive.zip"), { + method: "POST", + headers, + credentials: "include", + body: JSON.stringify({ file_ids: fileIds, filename }) + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + const blob = await response.blob(); + triggerDownload(blob, filename); +} + +function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx new file mode 100644 index 0000000..1001ef7 --- /dev/null +++ b/webui/src/features/files/FilesPage.tsx @@ -0,0 +1,1529 @@ +import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { ChevronRight, Copy, Download, File, Folder, Home, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; +import { + Button, + ConfirmDialog, + DismissibleAlert, + FieldLabel, + FormField, + LoadingIndicator, + ToggleSwitch, + hasScope, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + bulkDeleteFiles, + bulkRenameFiles, + createFolder, + deleteFolder, + downloadFile, + downloadFilesAsZip, + listFiles, + listFileSpaces, + listFolders, + resolveFilePatterns, + transferFiles, + uploadFiles, + type ConflictResolution, + type ConflictStrategy, + type FileFolder, + type FileSpace, + type ManagedFile, + type RenameResponse +} from "../../api/files"; +import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; +import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; +import type { + ContextMenuState, + DialogKind, + EntrySelectionKey, + ExplorerEntry, + FolderEntry, + FileActionTarget, + FileConflictItem, + RenameMode, + SelectionSets, + SortColumn, + SortDirection, + TransferMode +} from "./types"; +import { + buildExplorerEntries, + buildFolderEntryFromSources, + buildFolderTree, + candidateRenamedPath, + entrySelectionKey, + fileIdsForSelection, + folderAncestorPaths, + folderBreadcrumbs, + folderContentLabel, + formatBytes, + formatDate, + isFileInFolder, + joinFolder, + lastPathSegment, + normalizeFilePath, + normalizeFolder, + parseDragState, + parentFolderPath, + sortExplorerEntries, + treeNodeKey +} from "./utils/fileManagerUtils"; +import { useFileSelection } from "./hooks/useFileSelection"; +import { useFileTreeState } from "./hooks/useFileTreeState"; +import { useFileDialogs } from "./hooks/useFileDialogs"; +import { useFileDragDropState } from "./hooks/useFileDragDropState"; + +export default function FilesPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const canDownload = hasScope(auth, "files:download"); + const canUpload = hasScope(auth, "files:upload"); + const canOrganize = hasScope(auth, "files:organize"); + const canDelete = hasScope(auth, "files:delete"); + const [spaces, setSpaces] = useState(EMPTY_SPACES); + const [activeSpaceId, setActiveSpaceId] = useState(""); + const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null; + const [filesBySpace, setFilesBySpace] = useState>({}); + const [foldersBySpace, setFoldersBySpace] = useState>({}); + const files = activeSpace ? filesBySpace[activeSpace.id] ?? EMPTY_FILES : EMPTY_FILES; + const folders = activeSpace ? foldersBySpace[activeSpace.id] ?? EMPTY_FOLDERS : EMPTY_FOLDERS; + + const [currentFolder, setCurrentFolder] = useState(""); + const [searchPattern, setSearchPattern] = useState(""); + const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); + const [searchActive, setSearchActive] = useState(false); + const [searchResults, setSearchResults] = useState(null); + const [sortColumn, setSortColumn] = useState("name"); + const [sortDirection, setSortDirection] = useState("asc"); + const [unmatchedCount, setUnmatchedCount] = useState(null); + const [unpackZip, setUnpackZip] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const { dragActive, setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); + const { + dialog, + setDialog, + dialogTarget, + setDialogTarget, + transferMode, + setTransferMode, + transferDialogState, + setTransferDialogState, + contextMenu, + setContextMenu, + conflictDialog, + setConflictDialog, + deleteDialog, + setDeleteDialog, + openDialog: openRawDialog, + closeDialog: closeRawDialog + } = useFileDialogs(); + const [newFolderName, setNewFolderName] = useState(""); + const [newFolderError, setNewFolderError] = useState(""); + const [renamePreviewVisibleCount, setRenamePreviewVisibleCount] = useState(20); + const [singleRenameName, setSingleRenameName] = useState(""); + const [renameMode, setRenameMode] = useState("prefix"); + const [renameFind, setRenameFind] = useState(""); + const [renameReplacement, setRenameReplacement] = useState(""); + const [renamePrefix, setRenamePrefix] = useState(""); + const [renameSuffix, setRenameSuffix] = useState(""); + const [renameRecursive, setRenameRecursive] = useState(false); + const [renamePreview, setRenamePreview] = useState(null); + const fileInputRef = useRef(null); + + const visibleFiles = searchActive && searchResults ? searchResults : files; + const explorerEntries = useMemo(() => { + const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); + return sortExplorerEntries(entries, sortColumn, sortDirection); + }, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]); + const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]); + const { + selectedFileIds, + setSelectedFileIds, + selectedFolderPaths, + setSelectedFolderPaths, + selectedEntryCount, + hasSelection, + selectedSummary, + currentSelectionKeys, + setSelectionAnchor, + applySelectionKeys, + clearSelection, + currentSelectionSets, + setsForEntry, + isEntrySelected, + preventTextSelectionOnShift, + handleEntrySelection + } = useFileSelection(visibleEntryKeys, busy); + const { expandedTreeNodes, setExpandedTreeNodes, cancelTreeDragExpand, scheduleTreeDragExpand, toggleTreeFolder } = useFileTreeState({ + activeSpaceId, + currentFolder, + onOpenFolder: openFolder + }); + const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); + const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); + const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]); + const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); + const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; + const downloadLabel = selectedDownloadFileIds.length > 1 ? "Download ZIP" : "Download"; + + async function loadSpaces() { + setBusy(true); + setError(""); + try { + const response = await listFileSpaces(settings); + setSpaces(response.spaces); + setActiveSpaceId((current) => current || response.spaces[0]?.id || ""); + await Promise.all(response.spaces.map((space) => loadSpaceContents(space, { silent: true }))); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function loadSpaceContents(space = activeSpace, options: { silent?: boolean } = {}) { + if (!space) return; + if (!options.silent) { + setBusy(true); + setError(""); + setMessage(""); + } + try { + const [fileResponse, folderResponse] = await Promise.all([ + listFiles(settings, { owner_type: space.owner_type, owner_id: space.owner_id }), + listFolders(settings, { owner_type: space.owner_type, owner_id: space.owner_id }) + ]); + setFilesBySpace((current) => ({ ...current, [space.id]: fileResponse.files })); + setFoldersBySpace((current) => ({ ...current, [space.id]: folderResponse.folders })); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + setUnmatchedCount(null); + setRenamePreview(null); + if (!options.silent) { + setSearchActive(false); + setSearchPattern(""); + setSearchResults(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!options.silent) setBusy(false); + } + } + + useEffect(() => { + void loadSpaces(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + + useEffect(() => { + if (!contextMenu) return undefined; + const close = () => setContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setContextMenu(null); + }; + window.addEventListener("click", close); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [contextMenu]); + + useEffect(() => { + setCurrentFolder(""); + setSearchPattern(""); + setSearchActive(false); + setSearchResults(null); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + }, [activeSpaceId]); + + + + function resetTransientState(options: { keepSearch?: boolean } = {}) { + clearSelection(); + if (!options.keepSearch) { + setSearchActive(false); + setSearchPattern(""); + setSearchResults(null); + } + setUnmatchedCount(null); + setRenamePreview(null); + } + + function currentActionTarget(): FileActionTarget | null { + return activeDialogTarget; + } + + function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { + if (kind === "upload" && !canUpload) return; + if (kind && ["create-folder", "rename", "single-rename", "transfer"].includes(kind) && !canOrganize) return; + if (kind === "create-folder") { + setNewFolderName(""); + setNewFolderError(""); + } + openRawDialog(kind, target); + } + + function closeDialog() { + closeRawDialog(); + setNewFolderError(""); + } + + function updateActiveDialogFolder(spaceId: string, folderPath: string) { + setDialogTarget({ spaceId, folderPath: normalizeFolder(folderPath) }); + } + + function findSpace(spaceId: string): FileSpace | null { + return spaces.find((space) => space.id === spaceId) ?? null; + } + + + + async function handleFilesUpload(fileList: FileList | File[], options: { conflictStrategy?: ConflictStrategy; conflictResolutions?: ConflictResolution[]; bypassConflictDialog?: boolean; target?: FileActionTarget } = {}) { + if (!canUpload) return; + const target = options.target ?? currentActionTarget(); + const targetSpace = target ? findSpace(target.spaceId) : null; + if (!target || !targetSpace) return; + const selected = Array.from(fileList); + if (selected.length === 0) return; + if (!options.bypassConflictDialog && !unpackZip) { + const conflicts = uploadConflicts(selected, target); + if (conflicts.length > 0) { + closeDialog(); + setConflictDialog({ + operation: "upload", + title: `${conflicts.length} upload conflict(s)`, + message: "Files with the same visible name already exist in this folder.", + items: conflicts, + pendingUpload: { files: selected, target }, + review: false + }); + return; + } + } + setBusy(true); + setError(""); + setMessage(`Uploading ${selected.length} file(s)…`); + try { + const response = await uploadFiles(settings, selected, { + owner_type: targetSpace.owner_type, + owner_id: targetSpace.owner_id, + path: target.folderPath, + unpack_zip: unpackZip, + conflict_strategy: options.conflictStrategy ?? "reject", + conflict_resolutions: options.conflictResolutions + }); + setMessage(`Uploaded ${response.files.length} file(s) into ${target.folderPath || "Root"}.`); + closeDialog(); + setConflictDialog(null); + resetTransientState(); + await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setMessage(""); + } finally { + setBusy(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { + const previousTarget = dialogTarget; + setDialogTarget(target); + try { + await handleFilesUpload(fileList); + } finally { + setDialogTarget(previousTarget); + } + } + + async function handleCreateFolder() { + const target = currentActionTarget(); + const targetSpace = target ? findSpace(target.spaceId) : null; + const trimmedName = newFolderName.trim(); + if (!target || !targetSpace || !trimmedName) return; + const folderPath = joinFolder(target.folderPath, trimmedName); + if (folderExistsInTarget(target, folderPath)) { + setNewFolderError(`Folder already exists: ${folderPath}.`); + return; + } + if (pathExistsInTarget(target, folderPath, { includeFolders: false })) { + setNewFolderError(`A file already exists at ${folderPath}.`); + return; + } + setBusy(true); + setError(""); + setNewFolderError(""); + setMessage(""); + try { + await createFolder(settings, { + owner_type: targetSpace.owner_type, + owner_id: targetSpace.owner_id, + path: folderPath + }); + setMessage(`Created folder ${folderPath}.`); + closeDialog(); + setNewFolderName(""); + resetTransientState(); + await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); + setExpandedTreeNodes((current) => { + const next = new Set(current); + folderAncestorPaths(target.folderPath, { includeSelf: true }).forEach((path) => next.add(treeNodeKey(target.spaceId, path))); + return next; + }); + } catch (err) { + setNewFolderError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function runPatternSearch() { + if (!activeSpace || !searchPattern.trim()) { + await clearSearch(); + return; + } + setBusy(true); + setError(""); + setMessage(""); + try { + const response = await resolveFilePatterns(settings, { + patterns: [searchPattern.trim()], + owner_type: activeSpace.owner_type, + owner_id: activeSpace.owner_id, + path_prefix: currentFolder, + include_unmatched: true, + case_sensitive: searchCaseSensitive + }); + setSearchResults(response.patterns.flatMap((pattern) => pattern.matches)); + setSearchActive(true); + setUnmatchedCount(response.unmatched.length); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function clearSearch() { + setSearchPattern(""); + setSearchActive(false); + setSearchResults(null); + setUnmatchedCount(null); + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + } + + function deleteSelected( + fileIds: Set = selectedFileIds, + folderPaths: Set = selectedFolderPaths, + space: FileSpace | null = activeSpace + ) { + const fileCount = fileIds.size; + const folderCount = folderPaths.size; + if (!space || (fileCount === 0 && folderCount === 0)) return; + const folderText = folderCount ? `${folderCount} folder(s) and their contents` : ""; + const fileText = fileCount ? `${fileCount} file(s)` : ""; + const what = [folderText, fileText].filter(Boolean).join(" plus "); + setDeleteDialog({ + fileIds: new Set(fileIds), + folderPaths: new Set(folderPaths), + spaceId: space.id, + title: "Delete selected item(s)?", + message: `Delete ${what}? Audit-relevant blobs remain retained.` + }); + } + + async function performConfirmedDelete() { + const pending = deleteDialog; + if (!pending) return; + const space = findSpace(pending.spaceId); + if (!space) { + setDeleteDialog(null); + return; + } + setBusy(true); + setError(""); + setMessage(""); + try { + if (pending.fileIds.size > 0) { + await bulkDeleteFiles(settings, Array.from(pending.fileIds)); + } + if (pending.folderPaths.size > 0) { + for (const folderPath of pending.folderPaths) { + await deleteFolder(settings, { + owner_type: space.owner_type, + owner_id: space.owner_id, + path: folderPath, + recursive: true + }); + } + } + setDeleteDialog(null); + resetTransientState(); + await loadSpaceContents(space, { silent: space.id !== activeSpaceId }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + + function downloadLabelForSets(sets: SelectionSets, spaceId = activeSpaceId): string { + const count = fileIdsForSelection(filesBySpace[spaceId] ?? EMPTY_FILES, sets.fileIds, sets.folderPaths).length; + return count > 1 ? "Download ZIP" : "Download"; + } + + async function downloadSelection( + fileIds: Set = selectedFileIds, + folderPaths: Set = selectedFolderPaths, + sourceFiles: ManagedFile[] = files + ) { + if (!canDownload) return; + const idsToDownload = fileIdsForSelection(sourceFiles, fileIds, folderPaths); + if (idsToDownload.length === 0) return; + setBusy(true); + setError(""); + try { + if (idsToDownload.length === 1) { + const file = sourceFiles.find((item) => item.id === idsToDownload[0]); + if (file) await downloadFile(settings, file); + } else { + await downloadFilesAsZip(settings, idsToDownload, "multi-seal-mail-files.zip"); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function openTransferDialog(mode: TransferMode, sourceSpaceId = activeSpaceId, sets: SelectionSets = currentSelectionSets(), defaultTarget?: FileActionTarget) { + if (!canOrganize) return; + if (!sourceSpaceId || (sets.fileIds.size === 0 && sets.folderPaths.size === 0)) return; + setTransferMode(mode); + setTransferDialogState({ + fileIds: new Set(sets.fileIds), + folderPaths: new Set(sets.folderPaths), + sourceSpaceId, + targetSpaceId: defaultTarget?.spaceId ?? sourceSpaceId, + targetFolder: defaultTarget?.folderPath ?? currentFolder + }); + setDialog("transfer"); + } + + function invalidDropReason(sourceSpaceId: string, folderPaths: Iterable, target: FileActionTarget): string | null { + if (sourceSpaceId !== target.spaceId) return null; + const targetFolder = normalizeFolder(target.folderPath); + for (const folderPathRaw of folderPaths) { + const folderPath = normalizeFolder(folderPathRaw); + if (!folderPath) continue; + if (targetFolder === folderPath || targetFolder.startsWith(`${folderPath}/`)) { + return "Cannot move or copy a folder into itself or one of its child folders."; + } + } + return null; + } + + async function runTransfer(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget, options: { conflictStrategy?: ConflictStrategy; conflictResolutions?: ConflictResolution[]; bypassConflictDialog?: boolean } = {}) { + const sourceSpace = findSpace(sourceSpaceId); + const targetSpace = findSpace(target.spaceId); + if (!sourceSpace || !targetSpace) return; + const reason = invalidDropReason(sourceSpaceId, sets.folderPaths, target); + if (reason) { + setError(reason); + return; + } + if (sets.fileIds.size === 0 && sets.folderPaths.size === 0) return; + if (mode === "move" && transferWouldBeNoop(sourceSpaceId, sets, target)) { + setInternalDrag(null); + setDropTargetKey(""); + return; + } + if (!options.bypassConflictDialog) { + const conflicts = transferConflicts(mode, sourceSpaceId, sets, target); + if (conflicts.length > 0) { + closeDialog(); + setConflictDialog({ + operation: "transfer", + title: `${conflicts.length} conflict(s)`, + message: `${mode === "copy" ? "Copying" : "Moving"} this selection would create duplicate visible names in the destination.`, + items: conflicts, + pendingTransfer: { mode, sourceSpaceId, sets: { fileIds: new Set(sets.fileIds), folderPaths: new Set(sets.folderPaths) }, target }, + review: false + }); + setInternalDrag(null); + setDropTargetKey(""); + return; + } + } + setBusy(true); + setError(""); + setMessage(""); + try { + const response = await transferFiles(settings, { + operation: mode, + file_ids: Array.from(sets.fileIds), + folder_paths: Array.from(sets.folderPaths), + source_owner_type: sourceSpace.owner_type, + source_owner_id: sourceSpace.owner_id, + target_owner_type: targetSpace.owner_type, + target_owner_id: targetSpace.owner_id, + target_folder: target.folderPath, + conflict_strategy: options.conflictStrategy ?? "reject", + conflict_resolutions: options.conflictResolutions + }); + setMessage(`${mode === "copy" ? "Copied" : "Moved"} ${response.files} file(s) and ${response.folders} folder(s).`); + setConflictDialog(null); + closeDialog(); + resetTransientState(); + const reloadIds = new Set([sourceSpace.id, targetSpace.id]); + await Promise.all(Array.from(reloadIds).map((spaceId) => { + const space = findSpace(spaceId); + return space ? loadSpaceContents(space, { silent: space.id !== activeSpaceId }) : Promise.resolve(); + })); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + setInternalDrag(null); + setDropTargetKey(""); + } + } + + async function handleTransferDialogSubmit() { + if (!transferDialogState) return; + const state = transferDialogState; + const mode = transferMode; + closeDialog(); + await runTransfer(mode, state.sourceSpaceId, state, { + spaceId: state.targetSpaceId, + folderPath: state.targetFolder + }); + } + + async function runRenamePreview(dryRun: boolean) { + if (!activeSpace || selectedEntryCount === 0) return; + setBusy(true); + setError(""); + try { + const response = await bulkRenameFiles(settings, { + file_ids: Array.from(selectedFileIds), + folder_paths: Array.from(selectedFolderPaths), + owner_type: activeSpace.owner_type, + owner_id: activeSpace.owner_id, + mode: renameMode, + find: renameFind, + replacement: renameReplacement, + prefix: renamePrefix, + suffix: renameSuffix, + recursive: renameRecursive, + dry_run: dryRun + }); + setRenamePreview(response); + setRenamePreviewVisibleCount(20); + if (dryRun) { + setMessage(""); + } else { + setMessage(`Renamed ${response.items.length} item(s).`); + } + if (!dryRun) { + closeDialog(); + resetTransientState(); + await loadSpaceContents(activeSpace); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function openRenameDialog() { + if (!canOrganize || selectedEntryCount === 0) return; + setRenamePreview(null); + if (selectedEntryCount === 1) { + const selectedFile = selectedFiles[0]; + const selectedFolderPath = Array.from(selectedFolderPaths)[0]; + setSingleRenameName(selectedFile ? selectedFile.filename : lastPathSegment(selectedFolderPath)); + openDialog("single-rename"); + return; + } + openDialog("rename"); + } + + async function runSingleRename() { + if (!canOrganize || !activeSpace || selectedEntryCount !== 1 || !singleRenameName.trim()) return; + setBusy(true); + setError(""); + try { + const response = await bulkRenameFiles(settings, { + file_ids: Array.from(selectedFileIds), + folder_paths: Array.from(selectedFolderPaths), + owner_type: activeSpace.owner_type, + owner_id: activeSpace.owner_id, + mode: "direct", + new_name: singleRenameName, + dry_run: false + }); + setMessage(`Renamed ${response.items.length} item(s).`); + closeDialog(); + resetTransientState(); + await loadSpaceContents(activeSpace); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function openFolder(spaceId: string, path: string) { + if (spaceId !== activeSpaceId) setActiveSpaceId(spaceId); + setCurrentFolder(normalizeFolder(path)); + resetTransientState(); + } + + + function handleEntryKeyDown(entry: ExplorerEntry, event: ReactKeyboardEvent) { + if (event.key === " " || event.key === "Spacebar") { + event.preventDefault(); + handleEntrySelection(entry, event as unknown as ReactMouseEvent); + } + if (event.key === "Enter") { + event.preventDefault(); + if (entry.kind === "folder") activeSpace && openFolder(activeSpace.id, entry.path); + else if (canDownload) void downloadFile(settings, entry.file); + } + } + + function dragSetsForEntry(entry: ExplorerEntry): SelectionSets { + const key = entrySelectionKey(entry); + return currentSelectionKeys().has(key) ? currentSelectionSets() : setsForEntry(entry); + } + + function handleInternalDragStart(entry: ExplorerEntry, event: ReactDragEvent) { + if (!canOrganize || !activeSpace) { event.preventDefault(); return; } + const sets = dragSetsForEntry(entry); + if (!currentSelectionKeys().has(entrySelectionKey(entry))) { + applySelectionKeys(new Set([entrySelectionKey(entry)])); + setSelectionAnchor(entrySelectionKey(entry)); + } + const state = { sourceSpaceId: activeSpace.id, fileIds: Array.from(sets.fileIds), folderPaths: Array.from(sets.folderPaths) }; + setInternalDrag(state); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state)); + event.dataTransfer.setData("text/plain", `${state.fileIds.length + state.folderPaths.length} item(s)`); + } + + function handleTreeFolderDragStart(spaceId: string, path: string, event: ReactDragEvent) { + if (!canOrganize) { event.preventDefault(); return; } + const folderPath = normalizeFolder(path); + if (!folderPath) return; + const state = { sourceSpaceId: spaceId, fileIds: [], folderPaths: [folderPath] }; + setInternalDrag(state); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state)); + event.dataTransfer.setData("text/plain", folderPath); + } + + function isInternalDrag(event: ReactDragEvent): boolean { + return event.dataTransfer.types.includes(INTERNAL_DRAG_TYPE) || Boolean(internalDrag); + } + + function dropTargetId(target: FileActionTarget): string { + return `${target.spaceId}:${normalizeFolder(target.folderPath)}`; + } + + function handleDropTargetDragOver(event: ReactDragEvent, target: FileActionTarget) { + event.preventDefault(); + event.stopPropagation(); + if (isInternalDrag(event)) { + if (!canOrganize) { event.dataTransfer.dropEffect = "none"; setDropTargetKey(""); return; } + const state = internalDrag; + const mode = event.ctrlKey ? "copy" : "move"; + const sets = state ? { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) } : null; + const reason = state ? invalidDropReason(state.sourceSpaceId, state.folderPaths, target) : null; + const noop = state && sets && mode === "move" ? transferWouldBeNoop(state.sourceSpaceId, sets, target) : false; + event.dataTransfer.dropEffect = reason || noop ? "none" : mode; + setDropTargetKey(reason || noop ? "" : dropTargetId(target)); + return; + } + event.dataTransfer.dropEffect = canUpload ? "copy" : "none"; + setDropTargetKey(canUpload ? dropTargetId(target) : ""); + } + + async function handleDropOnTarget(event: ReactDragEvent, target: FileActionTarget) { + event.preventDefault(); + event.stopPropagation(); + setDragActive(false); + setDropTargetKey(""); + if (isInternalDrag(event)) { + if (!canOrganize) return; + const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE)); + if (!state) return; + const reason = invalidDropReason(state.sourceSpaceId, state.folderPaths, target); + if (reason) { + setError(reason); + return; + } + await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target); + return; + } + if (canUpload && event.dataTransfer.files.length > 0) { + await uploadExternalFilesToTarget(event.dataTransfer.files, target); + } + } + + function clearDropState() { + clearDragDropState(); + cancelTreeDragExpand(); + } + + function spaceForContext(menu: ContextMenuState | null): FileSpace | null { + const spaceId = menu?.spaceId ?? activeSpaceId; + return findSpace(spaceId) ?? activeSpace; + } + + function filesForContext(menu: ContextMenuState | null): ManagedFile[] { + const spaceId = menu?.spaceId ?? activeSpaceId; + return filesBySpace[spaceId] ?? files; + } + + function folderEntryForPath(spaceId: string, path: string): FolderEntry { + const sourceFiles = filesBySpace[spaceId] ?? EMPTY_FILES; + const sourceFolders = foldersBySpace[spaceId] ?? EMPTY_FOLDERS; + return buildFolderEntryFromSources(sourceFiles, sourceFolders, path); + } + + function selectedSetsForContext(menu: ContextMenuState | null): SelectionSets { + if (!menu) return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; + if (menu.source === "tree") { + if (menu.entry?.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([menu.entry.path]) }; + if (menu.folderPath !== undefined) { + const sourceFiles = filesForContext(menu); + const fileIds = new Set(sourceFiles.filter((file) => isFileInFolder(file, menu.folderPath ?? "")).map((file) => file.id)); + return { fileIds, folderPaths: new Set() }; + } + } + if (menu.target === "empty" || !menu.entry) { + return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; + } + const key = entrySelectionKey(menu.entry); + if (currentSelectionKeys().has(key)) { + return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; + } + return setsForEntry(menu.entry); + } + + function openContextMenu(event: ReactMouseEvent, target: "empty" | "folder" | "file", entry?: ExplorerEntry) { + event.preventDefault(); + event.stopPropagation(); + if (entry) { + const key = entrySelectionKey(entry); + const currentKeys = currentSelectionKeys(); + if (!currentKeys.has(key)) { + applySelectionKeys(new Set([key])); + setSelectionAnchor(key); + } + } + setContextMenu({ x: event.clientX, y: event.clientY, target, entry, source: "list", spaceId: activeSpaceId, folderPath: entry?.kind === "folder" ? entry.path : currentFolder }); + } + + function openTreeContextMenu(event: ReactMouseEvent, spaceId: string, path: string) { + event.preventDefault(); + event.stopPropagation(); + const normalizedPath = normalizeFolder(path); + const entry = normalizedPath ? folderEntryForPath(spaceId, normalizedPath) : undefined; + setContextMenu({ + x: event.clientX, + y: event.clientY, + target: entry ? "folder" : "empty", + entry, + source: "tree", + spaceId, + folderPath: normalizedPath + }); + } + + function openTreeEmptyContextMenu(event: ReactMouseEvent) { + event.preventDefault(); + const target = toolbarTarget(); + if (!target) return; + setContextMenu({ + x: event.clientX, + y: event.clientY, + target: "empty", + source: "tree", + spaceId: target.spaceId, + folderPath: target.folderPath + }); + } + + function contextActionTarget(menu: ContextMenuState | null): FileActionTarget | null { + const space = spaceForContext(menu); + if (!space) return null; + const folderPath = menu?.entry?.kind === "folder" ? menu.entry.path : menu?.folderPath ?? currentFolder; + return { spaceId: space.id, folderPath: normalizeFolder(folderPath) }; + } + + function openUploadDialogForContext(menu: ContextMenuState | null) { + const target = contextActionTarget(menu); + setContextMenu(null); + openDialog("upload", target); + } + + function openCreateFolderDialogForContext(menu: ContextMenuState | null) { + const target = contextActionTarget(menu); + setContextMenu(null); + openDialog("create-folder", target); + } + + async function downloadContextSelection(menu: ContextMenuState | null) { + const { fileIds, folderPaths } = selectedSetsForContext(menu); + const sourceFiles = filesForContext(menu); + setContextMenu(null); + await downloadSelection(fileIds, folderPaths, sourceFiles); + } + + async function deleteContextSelection(menu: ContextMenuState | null) { + const { fileIds, folderPaths } = selectedSetsForContext(menu); + const space = spaceForContext(menu); + setContextMenu(null); + await deleteSelected(fileIds, folderPaths, space); + } + + function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) { + const sets = selectedSetsForContext(menu); + const space = spaceForContext(menu); + const target = contextActionTarget(menu) ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); + setContextMenu(null); + if (space) openTransferDialog(mode, space.id, sets, target ?? undefined); + } + + function toolbarTarget(): FileActionTarget | null { + return activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null; + } + + function parentFolder(path: string): string { + const parts = normalizeFilePath(path).split("/").filter(Boolean); + return normalizeFolder(parts.slice(0, -1).join("/")); + } + + function filesInSpace(spaceId: string): ManagedFile[] { + return filesBySpace[spaceId] ?? EMPTY_FILES; + } + + function foldersInSpace(spaceId: string): FileFolder[] { + return foldersBySpace[spaceId] ?? EMPTY_FOLDERS; + } + + function pathExistsInTarget(target: FileActionTarget, path: string, options: { excludeFileId?: string; includeFolders?: boolean } = {}): boolean { + const normalized = normalizeFilePath(path); + const targetFiles = filesInSpace(target.spaceId); + if (targetFiles.some((file) => file.id !== options.excludeFileId && normalizeFilePath(file.display_path) === normalized)) return true; + if (options.includeFolders && folderExistsInTarget(target, normalized)) return true; + return false; + } + + function folderExistsInTarget(target: FileActionTarget, path: string): boolean { + const normalized = normalizeFolder(path); + return foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized); + } + + function uploadConflicts(uploadedFiles: File[], target: FileActionTarget): FileConflictItem[] { + const conflicts: FileConflictItem[] = []; + const reserved = new Set(); + uploadedFiles.forEach((file, index) => { + const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.name)); + if (pathExistsInTarget(target, targetPath, { includeFolders: true }) || reserved.has(targetPath)) { + conflicts.push({ + id: `upload:${index}:${targetPath}`, + kind: "file", + label: file.name, + targetPath, + action: "rename", + newPath: nextClientAvailablePath(target, targetPath, reserved) + }); + } + reserved.add(targetPath); + }); + return conflicts; + } + + function nextClientAvailablePath(target: FileActionTarget, desiredPath: string, reserved = new Set()): string { + const desired = normalizeFilePath(desiredPath); + if (!reserved.has(desired) && !pathExistsInTarget(target, desired, { includeFolders: true })) return desired; + for (let counter = 1; counter < 10000; counter += 1) { + const candidate = candidateRenamedPath(desired, counter); + if (!reserved.has(candidate) && !pathExistsInTarget(target, candidate, { includeFolders: true })) return candidate; + } + return desired; + } + + function transferWouldBeNoop(sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): boolean { + if (sourceSpaceId !== target.spaceId) return false; + const targetFolder = normalizeFolder(target.folderPath); + const sourceFiles = filesInSpace(sourceSpaceId); + const selectedItems = sets.fileIds.size + sets.folderPaths.size; + if (selectedItems === 0) return false; + for (const fileId of sets.fileIds) { + const file = sourceFiles.find((item) => item.id === fileId); + if (!file || parentFolder(file.display_path) !== targetFolder) return false; + } + for (const folderPath of sets.folderPaths) { + if (parentFolder(folderPath) !== targetFolder) return false; + } + return true; + } + + function transferConflicts(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): FileConflictItem[] { + const conflicts: FileConflictItem[] = []; + const sourceFiles = filesInSpace(sourceSpaceId); + const reserved = new Set(); + const maybeAddConflict = (id: string, kind: "file" | "folder", label: string, targetPath: string, excludeFileId?: string) => { + const normalized = kind === "folder" ? normalizeFolder(targetPath) : normalizeFilePath(targetPath); + const exists = kind === "folder" + ? foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized) + : pathExistsInTarget(target, normalized, { excludeFileId: mode === "move" && sourceSpaceId === target.spaceId ? excludeFileId : undefined }); + if (exists || reserved.has(normalized)) { + conflicts.push({ + id, + kind, + label, + targetPath: normalized, + action: "rename", + newPath: kind === "folder" ? nextClientAvailableFolderPath(target, normalized, reserved) : nextClientAvailablePath(target, normalized, reserved) + }); + } + reserved.add(normalized); + }; + + for (const fileId of sets.fileIds) { + const file = sourceFiles.find((item) => item.id === fileId); + if (!file) continue; + const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.filename)); + if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue; + maybeAddConflict(`file:${file.id}`, "file", file.filename, targetPath, file.id); + } + + for (const folderPathRaw of sets.folderPaths) { + const folderPath = normalizeFolder(folderPathRaw); + const folderName = lastPathSegment(folderPath); + const targetFolderPath = normalizeFolder(joinFolder(target.folderPath, folderName)); + if (mode === "move" && sourceSpaceId === target.spaceId && folderPath === targetFolderPath) continue; + maybeAddConflict(`folder:${folderPath}`, "folder", folderName, targetFolderPath); + for (const file of sourceFiles.filter((item) => isFileInFolder(item, folderPath))) { + const relative = normalizeFilePath(file.display_path).slice(folderPath.length + 1); + const targetPath = normalizeFilePath(joinFolder(targetFolderPath, relative)); + if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue; + maybeAddConflict(`folder-file:${folderPath}:${file.id}`, "file", relative, targetPath, file.id); + } + } + return conflicts; + } + + function nextClientAvailableFolderPath(target: FileActionTarget, desiredPath: string, reserved = new Set()): string { + const desired = normalizeFolder(desiredPath); + if (!reserved.has(desired) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === desired)) return desired; + for (let counter = 1; counter < 10000; counter += 1) { + const candidate = candidateRenamedPath(desired, counter); + if (!reserved.has(candidate) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === candidate)) return candidate; + } + return desired; + } + + function updateConflictItem(id: string, patch: Partial) { + setConflictDialog((current) => current ? { ...current, items: current.items.map((item) => item.id === id ? { ...item, ...patch } : item) } : current); + } + + async function applyConflictDecision(strategy: ConflictStrategy | "review") { + const currentConflict = conflictDialog; + if (!currentConflict) return; + if (strategy === "review" && !currentConflict.review) { + setConflictDialog({ ...currentConflict, review: true }); + return; + } + + const reviewed = currentConflict.review || strategy === "review"; + const conflictStrategy: ConflictStrategy = reviewed ? "reject" : (strategy as ConflictStrategy); + const conflictResolutions: ConflictResolution[] | undefined = reviewed + ? currentConflict.items.map((item) => ({ + target_path: item.targetPath, + action: item.action, + new_path: item.action === "rename" ? item.newPath : undefined + })) + : undefined; + + setConflictDialog(null); + + if (currentConflict.operation === "upload" && currentConflict.pendingUpload) { + const pending = currentConflict.pendingUpload; + let filesToUpload = pending.files; + let uploadResolutions = conflictResolutions; + if (reviewed) { + const skippedPaths = new Set(currentConflict.items.filter((item) => item.action === "skip").map((item) => item.targetPath)); + filesToUpload = pending.files.filter((file) => !skippedPaths.has(normalizeFilePath(joinFolder(pending.target.folderPath, file.name)))); + uploadResolutions = conflictResolutions?.filter((item) => item.action !== "skip"); + } + if (filesToUpload.length === 0) { + closeDialog(); + setMessage("No files uploaded; all conflicts were skipped."); + return; + } + await handleFilesUpload(filesToUpload, { + target: pending.target, + conflictStrategy, + conflictResolutions: uploadResolutions, + bypassConflictDialog: true + }); + return; + } + if (currentConflict.operation === "transfer" && currentConflict.pendingTransfer) { + const pending = currentConflict.pendingTransfer; + await runTransfer(pending.mode, pending.sourceSpaceId, pending.sets, pending.target, { + conflictStrategy, + conflictResolutions, + bypassConflictDialog: true + }); + } + } + + function toggleSort(column: SortColumn) { + if (sortColumn === column) { + setSortDirection((current) => current === "asc" ? "desc" : "asc"); + return; + } + setSortColumn(column); + setSortDirection("asc"); + } + + function sortLabel(column: SortColumn, label: string): string { + if (sortColumn !== column) return label; + return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`; + } + + const noticeTone = message.startsWith("No files") ? "warning" : message.endsWith("…") ? "info" : "success"; + + const toolbar = ( +
+ + + + + + {hasSelection && } + +
+ ); + + return ( +
+ {error && ( + {error} + )} + {message && !error && ( + {message} + )} + +
+ + +
+
+ {toolbar} + +
+
+
+ {selectedSummary} + {explorerEntries.filter((entry) => entry.kind === "folder").length} folder(s) · {explorerEntries.filter((entry) => entry.kind === "file").length} file(s) + {busy && Working…} + {unmatchedCount !== null && {unmatchedCount} visible file(s) were not matched.} + {selectedFiles.some((file) => file.audit_relevant) && Audit-relevant files stay retained when deleted from active browsing.} +
+
+ + + +
+
+ +
openContextMenu(event, "empty")} + onClick={(event) => { + if (event.target === event.currentTarget) applySelectionKeys(new Set()); + }} + > +
+ {(() => { + const normalizedCurrentFolder = normalizeFolder(currentFolder); + const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace); + const targetParentFolder = parentFolderPath(normalizedCurrentFolder); + return ( +
{ if (canOpenParent) applySelectionKeys(new Set()); }} + onDoubleClick={() => { + if (canOpenParent && activeSpace && !busy) openFolder(activeSpace.id, targetParentFolder); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && canOpenParent && activeSpace && !busy) { + event.preventDefault(); + openFolder(activeSpace.id, targetParentFolder); + } + }} + > +
+
+
+
+ + +
+ ); + })()} + {explorerEntries.length === 0 && ( +
This folder is empty. Use Upload or Create Folder to add content.
+ )} + {explorerEntries.map((entry) => { + const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null; + const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false; + return entry.kind === "folder" ? ( +
handleInternalDragStart(entry, event)} + onDragEnd={() => { setInternalDrag(null); clearDropState(); }} + onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)} + onDragLeave={clearDropState} + onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)} + onMouseDown={preventTextSelectionOnShift} + onClick={(event) => handleEntrySelection(entry, event)} + onDoubleClick={() => { if (!busy && activeSpace) openFolder(activeSpace.id, entry.path); }} + onContextMenu={(event) => openContextMenu(event, "folder", entry)} + onKeyDown={(event) => handleEntryKeyDown(entry, event)} + > +
+
+
+
+ {formatBytes(entry.totalSize)} + {formatDate(entry.updatedAt)} +
+ ) : ( +
handleInternalDragStart(entry, event)} + onDragEnd={() => { setInternalDrag(null); clearDropState(); }} + onMouseDown={preventTextSelectionOnShift} + onClick={(event) => handleEntrySelection(entry, event)} + onDoubleClick={() => { if (!busy) void downloadFile(settings, entry.file); }} + onContextMenu={(event) => openContextMenu(event, "file", entry)} + onKeyDown={(event) => handleEntryKeyDown(entry, event)} + > +
+
+
+
+ {formatBytes(entry.file.size_bytes)} + {formatDate(entry.file.updated_at)} +
+ ); + })} +
+
+
+ {busy && ( +
+
Loading files…
+
+ )} +
+ + {conflictDialog && ( + setConflictDialog(null)} + onCancel={() => setConflictDialog(null)} + onOverwrite={() => void applyConflictDecision("overwrite")} + onRename={() => void applyConflictDecision("rename")} + onReview={() => void applyConflictDecision("review")} + onApplyReview={() => void applyConflictDecision("review")} + onUpdateItem={updateConflictItem} + /> + )} + + void performConfirmedDelete()} + onCancel={() => setDeleteDialog(null)} + /> + + {contextMenu && ( + 0 || selectedSetsForContext(contextMenu).folderPaths.size > 0} + canCreateFolder={canOrganize} + canUpload={canUpload} + canDownload={canDownload} + canOrganize={canOrganize} + canDelete={canDelete} + downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)} + onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)} + onUpload={() => openUploadDialogForContext(contextMenu)} + onDownload={() => void downloadContextSelection(contextMenu)} + onMove={() => openTransferDialogForContext(contextMenu, "move")} + onCopy={() => openTransferDialogForContext(contextMenu, "copy")} + onDelete={() => void deleteContextSelection(contextMenu)} + /> + )} + + {dialog === "upload" && ( + +
{ + if (!busy && activeDialogSpace && canUpload) fileInputRef.current?.click(); + }} + onKeyDown={(event) => { + if ((event.key === "Enter" || event.key === " ") && !busy && activeDialogSpace && canUpload) { + event.preventDefault(); + fileInputRef.current?.click(); + } + }} + onDragOver={(event) => { event.preventDefault(); setDragActive(true); }} + onDragLeave={() => setDragActive(false)} + onDrop={(event) => { + event.preventDefault(); + setDragActive(false); + void handleFilesUpload(event.dataTransfer.files); + }} + > +
+ +
+ Destination folder + activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} + /> +
+ event.target.files && void handleFilesUpload(event.target.files)} /> +
+ + +
+
+ )} + + {dialog === "create-folder" && ( + + + { setNewFolderName(event.target.value); setNewFolderError(""); }} onKeyDown={(event) => { if (event.key === "Enter") void handleCreateFolder(); }} /> + Created inside {activeDialogTarget?.folderPath || "Root"}. + {newFolderError && {newFolderError}} + +
+ Parent folder + activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} + /> +
+
+ + +
+
+ )} + + {dialog === "transfer" && transferDialogState && ( + +

Choose a destination space and folder. Folders are transferred recursively.

+
+ + + +
+ Destination folder + setTransferDialogState((current) => current ? { ...current, targetFolder: folderPath } : current)} + /> +
+
+
+ + +
+
+ )} + + {dialog === "single-rename" && ( + + + setSingleRenameName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runSingleRename(); }} /> + +
+ + +
+
+ )} + + {dialog === "rename" && ( + +

Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.

+
+ + + + {renameMode === "prefix" && setRenamePrefix(event.target.value)} />} + {renameMode === "suffix" && setRenameSuffix(event.target.value)} />} + {renameMode === "replace" && <> setRenameFind(event.target.value)} /> setRenameReplacement(event.target.value)} />} +
+ {selectedFolderPaths.size > 0 && ( + + )} +
+ + +
+ {renamePreview && ( + setRenamePreviewVisibleCount((current) => current + 20)} + onShowFewer={() => setRenamePreviewVisibleCount(20)} + /> + )} +
+ )} +
+ ); +} + diff --git a/webui/src/features/files/components/FileManagerComponents.tsx b/webui/src/features/files/components/FileManagerComponents.tsx new file mode 100644 index 0000000..70e7c7c --- /dev/null +++ b/webui/src/features/files/components/FileManagerComponents.tsx @@ -0,0 +1,380 @@ +import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; +import { Button, Dialog } from "@govoplan/core-webui"; +import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; +import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; +import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; + +export function TransferFolderSelector({ + space, + nodes, + selectedFolder, + onSelect, + disabled +}: { + space: FileSpace | null; + nodes: FolderNode[]; + selectedFolder: string; + onSelect: (folderPath: string) => void; + disabled?: boolean; +}) { + return ( +
+ + {nodes.length === 0 && No folders yet. Choose the root folder.} + +
+ ); +} + +export function TransferFolderSelectorNodes({ + nodes, + selectedFolder, + onSelect, + disabled, + depth = 1 +}: { + nodes: FolderNode[]; + selectedFolder: string; + onSelect: (folderPath: string) => void; + disabled?: boolean; + depth?: number; +}) { + if (nodes.length === 0) return null; + return ( +
+ {nodes.map((node) => ( +
+ + +
+ ))} +
+ ); +} + +export function FolderTree({ + nodes, + activeSpaceId, + spaceId, + currentFolder, + dropTargetKey = "", + expandedKeys, + onOpen, + onToggle, + onContextMenu, + onDragOverTarget, + onDropOnTarget, + onClearDropState, + onRequestDragExpand, + onDragStartFolder, + onDragEndFolder, + dragDropEnabled = true, + contextMenuEnabled = true, + disabled, + depth = 1 +}: { + nodes: FolderNode[]; + activeSpaceId: string; + spaceId: string; + currentFolder: string; + dropTargetKey?: string; + expandedKeys: Set; + onOpen: (spaceId: string, path: string) => void; + onToggle: (spaceId: string, path: string) => void; + onContextMenu?: (event: ReactMouseEvent, spaceId: string, path: string) => void; + onDragOverTarget?: (event: ReactDragEvent, target: FileActionTarget) => void; + onDropOnTarget?: (event: ReactDragEvent, target: FileActionTarget) => Promise; + onClearDropState?: () => void; + onRequestDragExpand?: (spaceId: string, path: string) => void; + onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent) => void; + onDragEndFolder?: () => void; + dragDropEnabled?: boolean; + contextMenuEnabled?: boolean; + disabled?: boolean; + depth?: number; +}) { + if (nodes.length === 0) return null; + return ( +
+ {nodes.map((node) => { + const isActive = activeSpaceId === spaceId && currentFolder === node.path; + const target = { spaceId, folderPath: node.path }; + const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`; + const hasChildren = node.children.length > 0; + const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path)); + return ( +
+
onDragStartFolder(spaceId, node.path, event) : undefined} + onDragEnd={dragDropEnabled ? onDragEndFolder : undefined} + onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined} + onDragOver={dragDropEnabled && onDragOverTarget ? (event) => { + onDragOverTarget(event, target); + if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path); + } : undefined} + onDragLeave={dragDropEnabled ? onClearDropState : undefined} + onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined} + > + + +
+ {hasChildren && isExpanded && ( + + )} +
+ ); + })} +
+ ); +} + +export function RenamePreviewList({ + response, + selectedFileIds, + selectedFolderPaths, + recursive, + visibleCount, + onShowMore, + onShowFewer +}: { + response: RenameResponse; + selectedFileIds: Set; + selectedFolderPaths: Set; + recursive: boolean; + visibleCount: number; + onShowMore: () => void; + onShowFewer: () => void; +}) { + const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder); + const hiddenNonRecursiveItems = !recursive + ? response.items.filter((item) => { + if (item.kind === "file" && selectedFileIds.has(item.id)) return false; + if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false; + return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder)); + }).length + : 0; + const visibleItems = response.items.filter((item) => { + if (recursive) return true; + if (item.kind === "file") { + if (selectedFileIds.has(item.id)) return true; + return !selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder)); + } + return selectedFolderRoots.includes(normalizeFolder(item.old_path)); + }); + const safeVisibleCount = Math.max(20, visibleCount); + const shownItems = visibleItems.slice(0, safeVisibleCount); + const remaining = visibleItems.length - shownItems.length; + return ( +
+
+ {hiddenNonRecursiveItems > 0 && {hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.} + {shownItems.map((item) => {item.old_path}{item.new_path})} + {remaining > 0 && ( + + )} + {shownItems.length > 20 && ( + + )} +
+
+ ); +} + +export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FileContextMenu({ + menu, + hasSelection, + canCreateFolder, + canUpload, + canDownload, + canOrganize, + canDelete, + downloadLabel, + onCreateFolder, + onUpload, + onDownload, + onMove, + onCopy, + onDelete +}: { + menu: ContextMenuState; + hasSelection: boolean; + canCreateFolder: boolean; + canUpload: boolean; + canDownload: boolean; + canOrganize: boolean; + canDelete: boolean; + downloadLabel: string; + onCreateFolder: () => void; + onUpload: () => void; + onDownload: () => void; + onMove: () => void; + onCopy: () => void; + onDelete: () => void; +}) { + const showNewFolder = true; + const showDelete = menu.target !== "empty"; + const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth; + const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight; + const estimatedWidth = 220; + const estimatedHeight = 260; + const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8)); + const openUp = menu.y + estimatedHeight > viewportHeight; + const style: CSSProperties = openUp + ? { left, bottom: Math.max(8, viewportHeight - menu.y) } + : { left, top: Math.max(8, menu.y) }; + return ( +
event.stopPropagation()}> + {showNewFolder && } + + + + + {showDelete && } +
+ ); +} + +export function FileConflictDialog({ + state, + busy, + onClose, + onCancel, + onOverwrite, + onRename, + onReview, + onApplyReview, + onUpdateItem +}: { + state: ConflictDialogState; + busy: boolean; + onClose: () => void; + onCancel: () => void; + onOverwrite: () => void; + onRename: () => void; + onReview: () => void; + onApplyReview: () => void; + onUpdateItem: (id: string, patch: Partial) => void; +}) { + return ( + +

{state.message}

+
+ {state.items.length} conflict(s) + {state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."} +
+ {state.review && ( +
+ {state.items.map((item) => ( +
+
+ {item.label} + {item.kind === "folder" ? "Folder" : "File"} conflict at {item.targetPath} +
+ + onUpdateItem(item.id, { newPath: event.target.value })} + disabled={busy || item.action !== "rename"} + aria-label={`New path for ${item.label}`} + /> +
+ ))} +
+ )} + {!state.review && ( +
+ {state.items.slice(0, 8).map((item) => {item.targetPath})} + {state.items.length > 8 && … and {state.items.length - 8} more} +
+ )} +
+ + + + {!state.review && state.items.length > 1 && } + {state.review && } +
+
+ ); +} \ No newline at end of file diff --git a/webui/src/features/files/constants.ts b/webui/src/features/files/constants.ts new file mode 100644 index 0000000..4761379 --- /dev/null +++ b/webui/src/features/files/constants.ts @@ -0,0 +1,6 @@ +import type { FileFolder, FileSpace, ManagedFile } from "../../api/files"; + +export const EMPTY_SPACES: FileSpace[] = []; +export const EMPTY_FILES: ManagedFile[] = []; +export const EMPTY_FOLDERS: FileFolder[] = []; +export const INTERNAL_DRAG_TYPE = "application/x-multi-seal-mail-file-selection"; diff --git a/webui/src/features/files/hooks/useFileDialogs.ts b/webui/src/features/files/hooks/useFileDialogs.ts new file mode 100644 index 0000000..c959fdc --- /dev/null +++ b/webui/src/features/files/hooks/useFileDialogs.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { ConflictDialogState, ContextMenuState, DeleteDialogState, DialogKind, FileActionTarget, TransferDialogState, TransferMode } from "../types"; + +export function useFileDialogs() { + const [dialog, setDialog] = useState(null); + const [dialogTarget, setDialogTarget] = useState(null); + const [transferMode, setTransferMode] = useState("move"); + const [transferDialogState, setTransferDialogState] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [conflictDialog, setConflictDialog] = useState(null); + const [deleteDialog, setDeleteDialog] = useState(null); + + function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { + setDialogTarget(target); + setDialog(kind); + } + + function closeDialog() { + setDialog(null); + setDialogTarget(null); + setTransferDialogState(null); + } + + return { + dialog, + setDialog, + dialogTarget, + setDialogTarget, + transferMode, + setTransferMode, + transferDialogState, + setTransferDialogState, + contextMenu, + setContextMenu, + conflictDialog, + setConflictDialog, + deleteDialog, + setDeleteDialog, + openDialog, + closeDialog + }; +} diff --git a/webui/src/features/files/hooks/useFileDragDropState.ts b/webui/src/features/files/hooks/useFileDragDropState.ts new file mode 100644 index 0000000..a952f1e --- /dev/null +++ b/webui/src/features/files/hooks/useFileDragDropState.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; +import type { DragSelectionState } from "../types"; + +export function useFileDragDropState() { + const [dragActive, setDragActive] = useState(false); + const [internalDrag, setInternalDrag] = useState(null); + const [dropTargetKey, setDropTargetKey] = useState(""); + + function clearDropState() { + setDropTargetKey(""); + setDragActive(false); + } + + return { + dragActive, + setDragActive, + internalDrag, + setInternalDrag, + dropTargetKey, + setDropTargetKey, + clearDropState + }; +} diff --git a/webui/src/features/files/hooks/useFileSelection.ts b/webui/src/features/files/hooks/useFileSelection.ts new file mode 100644 index 0000000..2dd8627 --- /dev/null +++ b/webui/src/features/files/hooks/useFileSelection.ts @@ -0,0 +1,110 @@ +import { useMemo, useState, type MouseEvent as ReactMouseEvent } from "react"; +import type { ExplorerEntry, EntrySelectionKey, SelectionSets } from "../types"; +import { entrySelectionKey } from "../utils/fileManagerUtils"; + +export function useFileSelection(visibleEntryKeys: EntrySelectionKey[], busy: boolean) { + const [selectedFileIds, setSelectedFileIds] = useState>(() => new Set()); + const [selectedFolderPaths, setSelectedFolderPaths] = useState>(() => new Set()); + const [selectionAnchor, setSelectionAnchor] = useState(null); + + const selectedEntryCount = selectedFileIds.size + selectedFolderPaths.size; + const hasSelection = selectedEntryCount > 0; + const selectedSummary = `${selectedFileIds.size} file(s), ${selectedFolderPaths.size} folder(s) selected`; + + const currentSelectionKeySet = useMemo(() => { + const keys = new Set(); + selectedFileIds.forEach((id) => keys.add(`file:${id}`)); + selectedFolderPaths.forEach((path) => keys.add(`folder:${path}`)); + return keys; + }, [selectedFileIds, selectedFolderPaths]); + + function applySelectionKeys(keys: Set) { + const nextFileIds = new Set(); + const nextFolderPaths = new Set(); + keys.forEach((key) => { + if (key.startsWith("file:")) nextFileIds.add(key.slice(5)); + if (key.startsWith("folder:")) nextFolderPaths.add(key.slice(7)); + }); + setSelectedFileIds(nextFileIds); + setSelectedFolderPaths(nextFolderPaths); + } + + function clearSelection() { + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + setSelectionAnchor(null); + } + + function currentSelectionSets(): SelectionSets { + return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; + } + + function setsForEntry(entry: ExplorerEntry): SelectionSets { + if (entry.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([entry.path]) }; + return { fileIds: new Set([entry.id]), folderPaths: new Set() }; + } + + function isEntrySelected(entry: ExplorerEntry): boolean { + return entry.kind === "folder" ? selectedFolderPaths.has(entry.path) : selectedFileIds.has(entry.id); + } + + function preventTextSelectionOnShift(event: ReactMouseEvent) { + if (event.shiftKey) event.preventDefault(); + } + + function handleEntrySelection(entry: ExplorerEntry, event: ReactMouseEvent) { + if (busy) return; + const key = entrySelectionKey(entry); + const currentKeys = new Set(currentSelectionKeySet); + + if (event.shiftKey) event.preventDefault(); + + if (event.shiftKey && selectionAnchor) { + const startIndex = visibleEntryKeys.indexOf(selectionAnchor); + const endIndex = visibleEntryKeys.indexOf(key); + if (startIndex >= 0 && endIndex >= 0) { + const [from, to] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const nextKeys = event.ctrlKey || event.metaKey ? currentKeys : new Set(); + visibleEntryKeys.slice(from, to + 1).forEach((rangeKey) => nextKeys.add(rangeKey)); + applySelectionKeys(nextKeys); + setSelectionAnchor(key); + return; + } + } + + if (event.ctrlKey || event.metaKey) { + if (currentKeys.has(key)) currentKeys.delete(key); + else currentKeys.add(key); + applySelectionKeys(currentKeys); + setSelectionAnchor(key); + return; + } + + if (currentKeys.size === 1 && currentKeys.has(key)) { + applySelectionKeys(new Set()); + } else { + applySelectionKeys(new Set([key])); + setSelectionAnchor(key); + } + } + + return { + selectedFileIds, + setSelectedFileIds, + selectedFolderPaths, + setSelectedFolderPaths, + selectionAnchor, + setSelectionAnchor, + selectedEntryCount, + hasSelection, + selectedSummary, + currentSelectionKeys: () => currentSelectionKeySet, + applySelectionKeys, + clearSelection, + currentSelectionSets, + setsForEntry, + isEntrySelected, + preventTextSelectionOnShift, + handleEntrySelection + }; +} diff --git a/webui/src/features/files/hooks/useFileTreeState.ts b/webui/src/features/files/hooks/useFileTreeState.ts new file mode 100644 index 0000000..b295976 --- /dev/null +++ b/webui/src/features/files/hooks/useFileTreeState.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; + +export function useFileTreeState({ + activeSpaceId, + currentFolder, + onOpenFolder +}: { + activeSpaceId: string; + currentFolder: string; + onOpenFolder: (spaceId: string, path: string) => void; +}) { + const [expandedTreeNodes, setExpandedTreeNodes] = useState>(() => new Set()); + const dragExpandTimerRef = useRef(null); + const dragExpandKeyRef = useRef(null); + const suppressTreeAutoExpandKeyRef = useRef(null); + + useEffect(() => { + if (!activeSpaceId) return; + const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true }); + if (ancestors.length === 0) return; + const suppressedKey = suppressTreeAutoExpandKeyRef.current; + suppressTreeAutoExpandKeyRef.current = null; + setExpandedTreeNodes((current) => { + const next = new Set(current); + ancestors.forEach((path) => { + const key = treeNodeKey(activeSpaceId, path); + if (key !== suppressedKey) next.add(key); + }); + return next; + }); + }, [activeSpaceId, currentFolder]); + + function cancelTreeDragExpand() { + if (dragExpandTimerRef.current !== null) { + window.clearTimeout(dragExpandTimerRef.current); + dragExpandTimerRef.current = null; + } + dragExpandKeyRef.current = null; + } + + function expandTreeNode(spaceId: string, folderPath: string) { + const key = treeNodeKey(spaceId, folderPath); + setExpandedTreeNodes((current) => { + if (current.has(key)) return current; + const next = new Set(current); + next.add(key); + return next; + }); + } + + function scheduleTreeDragExpand(spaceId: string, folderPath: string) { + const key = treeNodeKey(spaceId, folderPath); + if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return; + cancelTreeDragExpand(); + dragExpandKeyRef.current = key; + dragExpandTimerRef.current = window.setTimeout(() => { + expandTreeNode(spaceId, folderPath); + dragExpandTimerRef.current = null; + dragExpandKeyRef.current = null; + }, 750); + } + + function toggleTreeFolder(spaceId: string, folderPath: string) { + const normalizedPath = normalizeFolder(folderPath); + const key = treeNodeKey(spaceId, normalizedPath); + const isExpanded = expandedTreeNodes.has(key); + if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) { + suppressTreeAutoExpandKeyRef.current = key; + onOpenFolder(spaceId, normalizedPath); + } + setExpandedTreeNodes((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + return { + expandedTreeNodes, + setExpandedTreeNodes, + cancelTreeDragExpand, + scheduleTreeDragExpand, + toggleTreeFolder + }; +} diff --git a/webui/src/features/files/types.ts b/webui/src/features/files/types.ts new file mode 100644 index 0000000..98afdd3 --- /dev/null +++ b/webui/src/features/files/types.ts @@ -0,0 +1,69 @@ +import type { ConflictAction, ManagedFile } from "../../api/files"; + +export type RenameMode = "prefix" | "suffix" | "replace"; +export type SortColumn = "name" | "size" | "modified"; +export type SortDirection = "asc" | "desc"; +export type TransferMode = "move" | "copy"; +export type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null; +export type EntrySelectionKey = `file:${string}` | `folder:${string}`; +export type ContextMenuState = { + x: number; + y: number; + target: "empty" | "folder" | "file"; + entry?: ExplorerEntry; + source?: "list" | "tree"; + spaceId?: string; + folderPath?: string; +}; +export type FileActionTarget = { spaceId: string; folderPath: string }; +export type SelectionSets = { fileIds: Set; folderPaths: Set }; +export type TransferDialogState = SelectionSets & { sourceSpaceId: string; targetSpaceId: string; targetFolder: string }; +export type DragSelectionState = { sourceSpaceId: string; fileIds: string[]; folderPaths: string[] }; +export type PendingUploadState = { files: File[]; target: FileActionTarget }; +export type PendingTransferState = { mode: TransferMode; sourceSpaceId: string; sets: SelectionSets; target: FileActionTarget }; +export type DeleteDialogState = SelectionSets & { spaceId: string; title: string; message: string }; +export type FileConflictItem = { + id: string; + kind: "file" | "folder"; + label: string; + targetPath: string; + action: ConflictAction; + newPath: string; +}; +export type ConflictDialogState = { + operation: "upload" | "transfer"; + title: string; + message: string; + items: FileConflictItem[]; + pendingUpload?: PendingUploadState; + pendingTransfer?: PendingTransferState; + review: boolean; +}; + +export type FolderNode = { + name: string; + path: string; + children: FolderNode[]; + fileCount: number; + persisted: boolean; +}; + +export type FolderEntry = { + kind: "folder"; + id: string; + name: string; + path: string; + fileCount: number; + folderCount: number; + totalSize: number; + updatedAt: string; + persisted: boolean; +}; + +export type FileEntry = { + kind: "file"; + id: string; + file: ManagedFile; +}; + +export type ExplorerEntry = FolderEntry | FileEntry; diff --git a/webui/src/features/files/utils/fileManagerUtils.ts b/webui/src/features/files/utils/fileManagerUtils.ts new file mode 100644 index 0000000..13be27b --- /dev/null +++ b/webui/src/features/files/utils/fileManagerUtils.ts @@ -0,0 +1,303 @@ +import type { FileFolder, ManagedFile } from "../../../api/files"; +import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types"; + +export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] { + const byPath = new Map(); + const ensureNode = (path: string, persisted: boolean): FolderNode | null => { + const normalized = normalizeFolder(path); + if (!normalized) return null; + const existing = byPath.get(normalized); + if (existing) { + existing.persisted = existing.persisted || persisted; + return existing; + } + const parts = normalized.split("/"); + const node: FolderNode = { + name: parts[parts.length - 1], + path: normalized, + children: [], + fileCount: 0, + persisted + }; + byPath.set(normalized, node); + const parentPath = parts.slice(0, -1).join("/"); + const parent = ensureNode(parentPath, false); + if (parent) parent.children.push(node); + return node; + }; + for (const folder of folders) ensureNode(folder.path, true); + for (const file of files) { + const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean); + for (let index = 0; index < parts.length - 1; index += 1) { + const node = ensureNode(parts.slice(0, index + 1).join("/"), false); + if (node) node.fileCount += 1; + } + } + const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/")); + sortFolderNodes(roots); + return roots; +} + +export function sortFolderNodes(nodes: FolderNode[]) { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + for (const node of nodes) sortFolderNodes(node.children); +} + +export function treeNodeKey(spaceId: string, folderPath: string): string { + return `${spaceId}:${normalizeFolder(folderPath)}`; +} + +export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] { + const parts = normalizeFolder(folderPath).split("/").filter(Boolean); + const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0); + const result: string[] = []; + for (let index = 1; index <= limit; index += 1) { + result.push(parts.slice(0, index).join("/")); + } + return result; +} + +export function isPathUnderOrSame(path: string, root: string): boolean { + const normalizedPath = normalizeFolder(path); + const normalizedRoot = normalizeFolder(root); + if (!normalizedRoot) return true; + return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); +} + +export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] { + if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file })); + const folder = normalizeFolder(currentFolder); + const prefix = folder ? `${folder}/` : ""; + const folderMap = new Map(); + const directFiles: FileEntry[] = []; + + for (const persisted of folders) { + const path = normalizeFolder(persisted.path); + if (!path.startsWith(prefix) || path === folder) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative || relative.includes("/")) continue; + folderMap.set(path, { + kind: "folder", + id: `folder:${path}`, + name: relative, + path, + fileCount: 0, + folderCount: 0, + totalSize: 0, + updatedAt: persisted.updated_at, + persisted: true + }); + } + + for (const file of files) { + const path = normalizeFilePath(file.display_path || file.filename); + if (prefix && !path.startsWith(prefix)) continue; + const relativePath = prefix ? path.slice(prefix.length) : path; + if (!relativePath) continue; + const slashIndex = relativePath.indexOf("/"); + if (slashIndex === -1) { + directFiles.push({ kind: "file", id: file.id, file }); + continue; + } + const folderName = relativePath.slice(0, slashIndex); + const folderPath = prefix ? `${folder}/${folderName}` : folderName; + const existing = folderMap.get(folderPath); + if (existing) { + existing.totalSize += file.size_bytes; + if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at; + } else { + folderMap.set(folderPath, { + kind: "folder", + id: `folder:${folderPath}`, + name: folderName, + path: folderPath, + fileCount: 0, + folderCount: 0, + totalSize: file.size_bytes, + updatedAt: file.updated_at, + persisted: false + }); + } + } + + for (const entry of folderMap.values()) { + const counts = directFolderCounts(files, folders, entry.path); + entry.fileCount = counts.files; + entry.folderCount = counts.folders; + } + + return [...Array.from(folderMap.values()), ...directFiles]; +} + +export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] { + const factor = direction === "asc" ? 1 : -1; + return [...entries].sort((a, b) => { + if (column === "name") { + if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1); + return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + } + if (column === "size") { + const delta = entrySize(a) - entrySize(b); + if (delta !== 0) return factor * delta; + return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + } + const timeA = entryModifiedTime(a); + const timeB = entryModifiedTime(b); + if (timeA !== timeB) return factor * (timeA - timeB); + return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + }); +} + +export function entryName(entry: ExplorerEntry): string { + return entry.kind === "folder" ? entry.name : entry.file.filename; +} + +export function entrySize(entry: ExplorerEntry): number { + return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes; +} + +export function entryModifiedTime(entry: ExplorerEntry): number { + const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at; + const parsed = new Date(raw).getTime(); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } { + const normalized = normalizeFolder(folderPath); + const prefix = normalized ? `${normalized}/` : ""; + const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length; + const childFolders = new Set(); + for (const folder of folders) { + const path = normalizeFolder(folder.path); + if (!path.startsWith(prefix) || path === normalized) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative) continue; + childFolders.add(relative.split("/")[0]); + } + for (const file of files) { + const path = normalizeFilePath(file.display_path); + if (!path.startsWith(prefix)) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative || !relative.includes("/")) continue; + childFolders.add(relative.split("/")[0]); + } + return { files: directFiles, folders: childFolders.size }; +} + +export function folderContentLabel(entry: FolderEntry): string { + if (entry.fileCount === 0 && entry.folderCount === 0) return "empty"; + const parts: string[] = []; + if (entry.fileCount > 0) parts.push(`${entry.fileCount} file(s)`); + if (entry.folderCount > 0) parts.push(`${entry.folderCount} folder(s)`); + return parts.join(", "); +} + +export function parentFolderPath(path: string): string { + const parts = normalizeFilePath(path).split("/").filter(Boolean); + return normalizeFolder(parts.slice(0, -1).join("/")); +} + +export function entrySelectionKey(entry: ExplorerEntry): EntrySelectionKey { + return entry.kind === "folder" ? `folder:${entry.path}` : `file:${entry.id}`; +} + +export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileFolder[], path: string): FolderEntry { + const normalizedPath = normalizeFolder(path); + const persistedFolder = folders.find((folder) => normalizeFolder(folder.path) === normalizedPath); + const childFiles = files.filter((file) => isFileInFolder(file, normalizedPath)); + const sortedDates = childFiles.map((file) => file.updated_at).sort(); + const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined; + const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString(); + return { + kind: "folder", + id: `folder:${normalizedPath}`, + name: lastPathSegment(normalizedPath) || "Root", + path: normalizedPath, + fileCount: directFolderCounts(files, folders, normalizedPath).files, + folderCount: directFolderCounts(files, folders, normalizedPath).folders, + totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0), + updatedAt, + persisted: Boolean(persistedFolder) + }; +} + +export function isFileInFolder(file: ManagedFile, folderPath: string): boolean { + const normalizedFolder = normalizeFolder(folderPath); + const filePath = normalizeFilePath(file.display_path); + if (!normalizedFolder) return true; + return filePath.startsWith(`${normalizedFolder}/`); +} + +export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set, selectedFolderPaths: Set): string[] { + const ids = new Set(selectedFileIds); + for (const folderPath of selectedFolderPaths) { + const normalizedFolder = normalizeFolder(folderPath); + for (const file of files) { + if (isFileInFolder(file, normalizedFolder)) ids.add(file.id); + } + } + return Array.from(ids); +} + +export function folderBreadcrumbs(folder: string): { name: string; path: string }[] { + const parts = normalizeFolder(folder).split("/").filter(Boolean); + return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") })); +} + +export function joinFolder(folder: string, name: string): string { + return normalizeFolder([folder, name].filter(Boolean).join("/")); +} + +export function normalizeFolder(value: string): string { + return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim(); +} + +export function normalizeFilePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim(); +} + +export function lastPathSegment(path: string): string { + const parts = normalizeFolder(path).split("/").filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : ""; +} + +export function candidateRenamedPath(path: string, counter: number): string { + const normalized = normalizeFilePath(path); + const parts = normalized.split("/").filter(Boolean); + const name = parts.pop() ?? normalized; + const lastDot = name.lastIndexOf("."); + const hasExtension = lastDot > 0; + const stem = hasExtension ? name.slice(0, lastDot) : name; + const ext = hasExtension ? name.slice(lastDot) : ""; + const suffix = counter === 1 ? " copy" : ` copy ${counter}`; + const nextName = `${stem}${suffix}${ext}`; + return normalizeFilePath([...parts, nextName].join("/")); +} + +export function parseDragState(value: string): DragSelectionState | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as Partial; + if (!parsed.sourceSpaceId || !Array.isArray(parsed.fileIds) || !Array.isArray(parsed.folderPaths)) return null; + return { sourceSpaceId: parsed.sourceSpaceId, fileIds: parsed.fileIds, folderPaths: parsed.folderPaths }; + } catch { + return null; + } +} + +export function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + const units = ["KB", "MB", "GB", "TB"]; + let size = value / 1024; + for (const unit of units) { + if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`; + size /= 1024; + } + return `${size.toFixed(1)} PB`; +} + +export function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..c3cde81 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,10 @@ +import "./styles/file-manager.css"; +export { default } from "./module"; +export * from "./module"; +export { default as FilesPage } from "./features/files/FilesPage"; +export * from "./api/files"; +export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; +export { FolderTree } from "./features/files/components/FileManagerComponents"; +export { useFileTreeState } from "./features/files/hooks/useFileTreeState"; +export type { FolderNode, SortColumn, SortDirection } from "./features/files/types"; +export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..bd2b025 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,12 @@ +import { Folder } from "lucide-react"; +import type { PlatformWebModule } from "@govoplan/core-webui"; + +export const filesModule: PlatformWebModule = { + id: "files", + label: "Files", + version: "1.0.0", + dependencies: ["access"], + navItems: [{ to: "/files", label: "Files", icon: Folder, anyOf: ["files:file:read"], order: 40 }] +}; + +export default filesModule; diff --git a/webui/src/styles/file-manager.css b/webui/src/styles/file-manager.css new file mode 100644 index 0000000..8afc472 --- /dev/null +++ b/webui/src/styles/file-manager.css @@ -0,0 +1,721 @@ +/* Files manager */ +.file-manager-page.file-manager-fullscreen { + position: relative; + display: grid; + grid-template-rows: 1fr; + height: calc(100vh - 115px); + padding: 0; + overflow: hidden; +} + +.file-manager-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: var(--panel-header); +} + +.file-manager-toolbar .button { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.file-manager-shell { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + min-height: 0; + height: 100%; + border: 1px solid var(--line); + border-radius: 0; + overflow: hidden; + background: var(--panel); +} + +.file-tree-panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-right: 1px solid var(--line); + background: linear-gradient(180deg, var(--panel-soft), var(--panel)); +} + +.file-tree-heading { + flex: 0 0 auto; + padding: 13px 14px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 800; + letter-spacing: .05em; + text-transform: uppercase; +} + +.file-tree-list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 10px 8px 16px; +} + +.file-tree-space + .file-tree-space { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.file-tree-node-wrap { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + align-items: center; + gap: 2px; + border-radius: 9px; +} + +.file-tree-node, +.file-tree-select, +.file-tree-toggle { + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; +} + +.file-tree-toggle { + display: grid; + place-items: center; + width: 26px; + height: 32px; + border-radius: 9px; + color: var(--muted); +} + +.file-tree-toggle:disabled { + cursor: default; + opacity: .55; +} + +.file-tree-node { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + padding: 8px 9px; + border-radius: 9px; + font: inherit; + text-align: left; + user-select: none; +} + +.file-tree-node span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tree-root { + font-weight: 800; +} + +.file-tree-select { + width: 24px; + height: 24px; + border-radius: 999px; + color: var(--text-strong); + font-weight: 900; + line-height: 1; +} + +.file-tree-node-wrap:hover, +.file-tree-node-wrap:focus-within, +.file-tree-node-wrap.is-active, +.file-tree-root:hover:not(:disabled), +.file-tree-root:focus-visible, +.file-tree-root.is-active { + background: rgba(13, 110, 253, .08); + outline: none; +} + +.file-tree-node:focus-visible, +.file-tree-toggle:focus-visible, +.file-tree-select:focus-visible { + outline: none; +} + +.file-tree-node-wrap.is-drop-target, +.file-tree-root.is-drop-target { + background: rgba(13, 110, 253, .14); + box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); +} + +.file-tree-node-wrap.is-selected .file-tree-select { + background: #0d6efd; + color: #fff; +} + +.file-tree-children { + display: grid; + gap: 1px; +} + +.file-list-panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--panel); +} + +.file-list-sticky { + flex: 0 0 auto; + z-index: 2; + border-bottom: 1px solid var(--line); + background: var(--panel-soft); +} + +.file-breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-height: 32px; + padding: 10px 14px 6px; +} + +.file-breadcrumb, +.file-breadcrumb-segment { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.file-breadcrumb { + border: 0; + background: transparent; + color: var(--text-strong); + cursor: pointer; + font-weight: 800; + padding: 4px 6px; + border-radius: 7px; +} + +.file-breadcrumb:hover:not(:disabled), +.file-breadcrumb:focus-visible { + background: var(--panel); + outline: none; +} + +.file-search-row { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto auto auto; + gap: 18px; + align-items: center; + padding: 4px 0 10px 14px; +} + +.file-list-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 8px 14px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} + +.file-list-drop-target { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + transition: background-color .16s ease, box-shadow .16s ease; +} + +.file-list-drop-target.is-active, +.file-list-drop-target.is-drop-target { + background: rgba(13, 110, 253, .05); + box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .22); +} + +.file-list-table { + min-width: 760px; +} + +.file-list-table-head, +.file-list-row { + display: grid; + grid-template-columns: minmax(280px, 1fr) 120px 240px; + gap: 12px; + align-items: center; +} + +.file-list-table-head { + padding: 10px 14px; + border-top: 1px solid var(--line); + background: var(--panel); + color: var(--muted); + font-size: 12px; + font-weight: 800; + letter-spacing: .04em; + text-transform: uppercase; +} + +.file-list-table-head button { + justify-self: start; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + font-weight: inherit; + letter-spacing: inherit; + padding: 0; + text-transform: inherit; +} + +.file-list-table-head button:hover, +.file-list-table-head button:focus-visible { + color: var(--text-strong); + outline: none; +} + +.file-list-row { + min-height: 58px; + padding: 9px 14px; + border-bottom: 1px solid var(--line); + cursor: default; + user-select: none; +} + +.file-list-row:focus-visible { + outline: 2px solid rgba(13, 110, 253, .35); + outline-offset: -2px; +} + +.file-list-row:hover, +.file-list-row.is-selected { + background: rgba(13, 110, 253, .07); +} + +.file-list-row.is-drop-target { + background: rgba(13, 110, 253, .13); + box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); +} + +.file-parent-row.is-disabled { + color: var(--muted); + cursor: default; + opacity: .58; +} + +.file-parent-row.is-disabled:hover { + background: transparent; +} + +.file-list-name-cell { + min-width: 0; +} + +.file-list-name { + display: inline-flex; + align-items: center; + gap: 10px; + max-width: 100%; + min-width: 0; + border: 0; + background: transparent; + color: var(--text); + cursor: default; + font: inherit; + text-align: left; + padding: 0; +} + +.file-list-name > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.file-list-name strong, +.file-list-name small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-list-name small { + color: var(--muted); + font-size: 12px; +} + +.file-row-icon { + color: var(--muted); + flex: 0 0 auto; +} + +.folder-row .file-row-icon { + color: #b6791d; +} + +.file-row-tail { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; + color: var(--muted); + font-size: 12px; +} + +.file-list-empty { + padding: 36px 20px; + color: var(--muted); + text-align: center; +} + +.file-context-menu { + position: fixed; + z-index: 110; + display: grid; + min-width: 190px; + overflow: hidden; + border: 1px solid var(--line-dark); + border-radius: 12px; + background: var(--panel); + box-shadow: var(--shadow-popover); + padding: 6px; +} + +.file-context-menu button { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 9px 10px; + text-align: left; +} + +.file-context-menu button:hover, +.file-context-menu button:focus-visible { + background: rgba(13, 110, 253, .08); + outline: none; +} + +.file-context-menu button.danger { + color: var(--danger-text); +} + + +.file-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 22px; + background: rgba(28, 25, 22, .38); +} + +.file-dialog { + width: min(620px, 100%); + max-height: min(720px, calc(100vh - 44px)); + overflow: auto; + border: 1px solid var(--line-dark); + border-radius: 16px; + background: var(--panel); + box-shadow: var(--shadow-popover); +} + +.file-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 15px 18px; + border-bottom: 1px solid var(--line); + background: var(--panel-soft); +} + +.file-dialog-header h3, +.file-dialog-header .file-dialog-title { + margin: 0; + color: var(--text-strong); + font-size: 18px; +} + +.file-dialog-header button { + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 26px; + line-height: 1; +} + +.file-dialog-body { + display: grid; + gap: 16px; + padding: 18px; +} + +.file-upload-drop-zone { + display: grid; + place-items: center; + gap: 8px; + min-height: 170px; + border: 1px dashed var(--line-dark); + border-radius: 14px; + background: var(--panel-soft); + color: var(--muted); + text-align: center; + cursor: pointer; + transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; +} + +.file-upload-drop-zone:hover, +.file-upload-drop-zone:focus-visible, +.file-upload-drop-zone.is-active { + border-color: #0d6efd; + background: rgba(13, 110, 253, .08); +} + +.file-upload-drop-zone:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(13, 110, 253, .18); +} + +.file-upload-drop-zone[aria-disabled="true"] { + cursor: not-allowed; + opacity: .65; +} + +.field-error { + color: var(--danger-text); + font-weight: 700; +} + +.inline-link-button { + justify-self: start; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-weight: 800; + padding: 0; + text-align: left; +} + +.inline-link-button:hover, +.inline-link-button:focus-visible { + text-decoration: underline; + outline: none; +} + + +.file-search-row .toggle-switch-row { + margin: 0; + white-space: nowrap; +} + +.file-folder-selector { + display: grid; + gap: 2px; + max-height: 290px; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 8px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); +} + +.file-folder-selector-node { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 8px 10px; + text-align: left; +} + +.file-folder-selector-node span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-folder-selector-node:hover:not(:disabled), +.file-folder-selector-node:focus-visible, +.file-folder-selector-node.is-selected { + background: rgba(13, 110, 253, .09); + outline: none; +} + +.file-folder-selector-node.is-selected { + color: var(--text-strong); + box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .25); +} + +.file-folder-selector-empty { + padding: 6px 10px 2px; +} + +.file-folder-selector-children { + display: grid; + gap: 2px; +} + +@media (max-width: 1050px) { + .file-manager-shell { + grid-template-columns: 1fr; + } + + .file-tree-panel { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .file-list-table-head { + display: none; + } + + .file-list-table { + min-width: 0; + } + + .file-list-row { + grid-template-columns: 1fr; + gap: 8px; + } + + .file-search-row { + grid-template-columns: 1fr; + } +} + +.file-manager-shell.is-loading .file-tree-panel, +.file-manager-shell.is-loading .file-list-panel { + filter: blur(1.5px); + pointer-events: none; + user-select: none; +} + +.file-manager-loading-overlay { + position: absolute; + inset: 0; + z-index: 35; + display: grid; + place-items: center; + background: rgba(255, 255, 255, .12); + backdrop-filter: blur(1px); +} + +.file-conflict-summary { + display: grid; + gap: 3px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); +} + +.file-conflict-summary span { + color: var(--muted); + font-size: 13px; +} + +.file-conflict-list { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; +} + +.file-conflict-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px minmax(180px, 1fr); + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); +} + +.file-conflict-row > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.file-conflict-row small, +.file-conflict-row code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-conflict-row small { + color: var(--muted); +} + +@media (max-width: 900px) { + .file-conflict-row { + grid-template-columns: 1fr; + } +} + +.rename-preview-panel { + max-height: min(360px, 42vh); + overflow-y: auto; + padding-right: 4px; + scrollbar-gutter: stable; +} + +.rename-preview-more { + display: block; + width: 100%; + border: 1px dashed var(--line-dark); + border-radius: 6px; + background: var(--panel-soft); + color: var(--muted); + cursor: pointer; + font: inherit; + font-weight: 800; + padding: 10px 12px; + text-align: left; +} + +.rename-preview-more:hover, +.rename-preview-more:focus-visible { + border-color: rgba(13, 110, 253, .45); + background: rgba(13, 110, 253, .08); + color: var(--text-strong); + outline: none; +}