from __future__ import annotations import json from typing import Literal from fastapi import APIRouter, Depends, File as FastAPIFile, Form, UploadFile from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, require_scope from govoplan_files.backend.schemas import ( ConflictResolutionRequest, FileUploadResponse, _conflict_resolutions, ) from govoplan_files.backend.db.models import FileAsset from govoplan_core.db.session import get_session from govoplan_files.backend.runtime import settings from govoplan_files.backend.storage.paths import UnsafeFilePathError from govoplan_files.backend.storage.archives import extract_zip_upload from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.connector_policy import ( ConnectorPolicyDenied, ) from govoplan_files.backend.storage.files import ( create_file_asset, ) from govoplan_files.backend.route_support import ( _asset_response, _audit_connector_imports, _cleanup_temp_file, _connector_policy_error, _enforce_connector_policy, _http_error, _is_admin, _read_limited_upload, _source_metadata_from_form, _spool_limited_upload_to_temp, ) router = APIRouter(prefix="/files", tags=["files"]) @router.post("/upload", response_model=FileUploadResponse) 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), source_provenance_json: str | None = Form(default=None), source_revision: str | None = Form(default=None), connector_policy_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] ) _enforce_connector_policy( source_provenance_json, connector_policy_json, operation="import" ) metadata = _source_metadata_from_form(source_provenance_json, source_revision) 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 ) if unpack_zip and filename.lower().endswith(".zip"): zip_path = _spool_limited_upload_to_temp( upload, max_bytes=upload_limit, suffix=".zip" ) try: 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=zip_path, folder=path, campaign_id=campaign_id, conflict_strategy=conflict_strategy, conflict_resolutions=upload_resolutions, metadata=metadata, is_admin=_is_admin(principal), max_file_bytes=settings.file_upload_max_bytes, max_total_bytes=settings.file_upload_zip_max_bytes, ) finally: _cleanup_temp_file(zip_path) uploaded_assets.extend(item.asset for item in extracted) continue data = _read_limited_upload(upload, max_bytes=upload_limit) 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, metadata=metadata, is_admin=_is_admin(principal), ) uploaded_assets.append(stored.asset) _audit_connector_imports(session, principal, uploaded_assets) session.commit() except ConnectorPolicyDenied as exc: session.rollback() raise _connector_policy_error(exc) from exc 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) 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), source_provenance_json: str | None = Form(default=None), source_revision: str | None = Form(default=None), connector_policy_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 zip_path: str | None = None try: raw_resolutions = ( json.loads(conflict_resolutions_json) if conflict_resolutions_json else [] ) upload_resolutions = _conflict_resolutions( [ConflictResolutionRequest(**item) for item in raw_resolutions] ) _enforce_connector_policy( source_provenance_json, connector_policy_json, operation="import" ) metadata = _source_metadata_from_form(source_provenance_json, source_revision) zip_path = _spool_limited_upload_to_temp( file, max_bytes=settings.file_upload_zip_max_bytes, suffix=".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=zip_path, folder=path, campaign_id=campaign_id, conflict_strategy=conflict_strategy, conflict_resolutions=upload_resolutions, metadata=metadata, is_admin=_is_admin(principal), max_file_bytes=settings.file_upload_max_bytes, max_total_bytes=settings.file_upload_zip_max_bytes, ) _audit_connector_imports(session, principal, [item.asset for item in extracted]) session.commit() except ConnectorPolicyDenied as exc: session.rollback() raise _connector_policy_error(exc) from exc except (FileStorageError, UnsafeFilePathError, ValueError) as exc: session.rollback() raise _http_error(exc) from exc finally: if zip_path: _cleanup_temp_file(zip_path) return FileUploadResponse( files=[ _asset_response(session, item.asset, include_shares=True) for item in extracted ] )