"""DMS plugin routes — folders, files, preview, Collabora, internal sharing, search, bulk.""" from __future__ import annotations import os import uuid from fastapi import ( APIRouter, Body, Depends, File, Form, HTTPException, Response, UploadFile, status, ) from fastapi.responses import StreamingResponse from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.storage import get_storage_backend, LocalStorage from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.deps import get_current_user, require_permission from app.plugins.builtins.dms.models import File as DmsFile from app.plugins.builtins.dms.models import Folder from app.plugins.builtins.dms.schemas import ( BulkDeleteRequest, BulkMoveRequest, FileUpdate, FolderCreate, FolderUpdate, ShareRemoveRequest, ShareRequest, ) from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract # Get Permission model from the permissions contract _perms_contract = get_perms_contract() Permission = _perms_contract.Permission router = APIRouter(prefix="/api/v1/dms", tags=["dms"]) # Office file extensions mapped to Collabora file types OFFICE_EXTENSIONS = { ".docx": "docx", ".xlsx": "xlsx", ".pptx": "pptx", } # Max file size: 100 MB MAX_FILE_SIZE = 100 * 1024 * 1024 def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str: """Build relative storage path for a file (relative to storage base).""" return f"{tenant_id}/{file_id}" def _get_file_extension(filename: str) -> str: """Extract lowercase extension including dot.""" return os.path.splitext(filename)[1].lower() def _sanitize_filename(filename: str) -> str: """Sanitize a filename for safe use in Content-Disposition headers.""" import re # Extract basename only (strip any path components) safe = os.path.basename(filename.replace('\\', '/')) # Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode) safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe) # Collapse consecutive dots (path traversal prevention) safe = re.sub(r'\.{2,}', '_', safe) # Collapse multiple spaces safe = re.sub(r' {2,}', ' ', safe) # Strip leading dots and whitespace safe = safe.lstrip('.').strip() # Limit length if len(safe) > 200: name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '') safe = name[:200] + ('.' + ext if ext else '') return safe or 'file' # Blocked file extensions for security BLOCKED_EXTENSIONS = { ".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf", ".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess", ".phtml", ".pht", ".cgi", ".cfm", ".erb", } # Allowed MIME types for upload validation ALLOWED_MIME_PREFIXES = { "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument", "application/vnd.oasis.opendocument", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/zip", "application/gzip", "application/x-tar", "application/json", "application/xml", "application/rtf", "application/x-7z-compressed", "application/x-rar-compressed", "text/plain", "text/csv", "text/html", "text/markdown", "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/tiff", "image/x-icon", "audio/", "video/", "application/octet-stream", } def _is_blocked_filetype(filename: str) -> bool: """Check if a file has a blocked (dangerous) extension.""" ext = os.path.splitext(filename)[1].lower() return ext in BLOCKED_EXTENSIONS CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming uploads # ─── Folders ─── @router.get("/folders", dependencies=[Depends(require_permission("dms:read"))]) async def list_folders( parent_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).""" tenant_id = uuid.UUID(current_user["tenant_id"]) # Fetch all non-deleted folders for tenant with visibility filter user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" query = select(Folder).where( Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin ) result = await db.execute(query) all_folders = result.scalars().all() # Build lookup map folder_map: dict[uuid.UUID, dict] = {} for f in all_folders: folder_map[f.id] = { "id": str(f.id), "name": f.name, "parent_id": str(f.parent_id) if f.parent_id else None, "created_by": str(f.created_by), "deleted_at": None, "path": "", "children": [], } # Build path for each folder def _build_path(folder_id: uuid.UUID) -> str: if folder_id not in folder_map: return "" f = folder_map[folder_id] if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map: parent_path = _build_path(uuid.UUID(f["parent_id"])) return f"{parent_path}/{f['name']}" return f["name"] for fid in folder_map: folder_map[fid]["path"] = _build_path(fid) # Build tree root_nodes: list[dict] = [] target_parent: uuid.UUID | None = None if parent_id is not None: target_parent = _parse_uuid(parent_id, "parent_id") for f in all_folders: node = folder_map[f.id] if f.parent_id is not None and f.parent_id in folder_map: folder_map[f.parent_id]["children"].append(node) elif f.parent_id is None: root_nodes.append(node) if target_parent is not None: # Return children of specified parent parent_node = folder_map.get(target_parent) if parent_node is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) return parent_node["children"] return root_nodes @router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))]) async def create_folder( body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC2: POST /api/v1/dms/folders → 201, folder created with path.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None # Validate parent exists if specified if parent_id is not None: parent_result = await db.execute( select(Folder).where( Folder.id == parent_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if parent_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) # Check name uniqueness within same parent (non-deleted) existing = await db.execute( select(Folder).where( Folder.tenant_id == tenant_id, Folder.name == body.name, Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None), Folder.deleted_at.is_(None), ) ) if existing.scalar_one_or_none() is not None: raise HTTPException( 409, detail={"detail": "Folder name already exists", "code": "duplicate"} ) folder = Folder( tenant_id=tenant_id, name=body.name, parent_id=parent_id, created_by=user_id, ) db.add(folder) await db.flush() # Build path path = body.name if parent_id is not None: parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id)) parent_folder = parent_path_result.scalar_one_or_none() if parent_folder: # Recursively build path path_parts = [body.name] current = parent_folder while current is not None: path_parts.insert(0, current.name) if current.parent_id is not None: cur_result = await db.execute( select(Folder).where(Folder.id == current.parent_id) ) current = cur_result.scalar_one_or_none() else: current = None path = "/".join(path_parts) return { "id": str(folder.id), "name": folder.name, "parent_id": str(folder.parent_id) if folder.parent_id else None, "created_by": str(folder.created_by), "deleted_at": None, "path": path, "children": [], } @router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))]) async def update_folder( folder_id: str, body: FolderUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(folder_id, "folder_id") result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) folder = result.scalar_one_or_none() if folder is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) data = body.model_dump(exclude_unset=True) if "name" in data and data["name"] is not None: # Check uniqueness if name is changing new_parent_id = folder.parent_id if "parent_id" in data and data["parent_id"] is not None: new_parent_id = _parse_uuid(data["parent_id"], "parent_id") dup = await db.execute( select(Folder).where( Folder.tenant_id == tenant_id, Folder.name == data["name"], Folder.id != fid, Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None), Folder.deleted_at.is_(None), ) ) if dup.scalar_one_or_none() is not None: raise HTTPException( 409, detail={"detail": "Folder name already exists", "code": "duplicate"} ) folder.name = data["name"] if "parent_id" in data: new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None if new_parent is not None: # Validate parent exists and not creating a cycle if new_parent == fid: raise HTTPException( 400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"} ) parent_result = await db.execute( select(Folder).where( Folder.id == new_parent, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if parent_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) # Check for cycle: ensure new_parent is not a descendant of folder async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool: cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id)) cur = cur_result.scalar_one_or_none() while cur is not None and cur.parent_id is not None: if cur.parent_id == ancestor_id: return True p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id)) cur = p_result.scalar_one_or_none() return False if await _is_descendant(fid, new_parent): raise HTTPException( 400, detail={ "detail": "Cannot move folder into its own descendant", "code": "invalid_move", }, ) folder.parent_id = new_parent await db.flush() # Build path path_parts = [folder.name] current_id = folder.parent_id while current_id is not None: cur_result = await db.execute(select(Folder).where(Folder.id == current_id)) cur = cur_result.scalar_one_or_none() if cur is None: break path_parts.insert(0, cur.name) current_id = cur.parent_id path = "/".join(path_parts) return { "id": str(folder.id), "name": folder.name, "parent_id": str(folder.parent_id) if folder.parent_id else None, "created_by": str(folder.created_by), "deleted_at": None, "path": path, "children": [], } @router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))]) async def delete_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(folder_id, "folder_id") result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) folder = result.scalar_one_or_none() if folder is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) from datetime import UTC, datetime now = datetime.now(UTC) # Recursively collect all descendant folder IDs all_folder_ids: list[uuid.UUID] = [fid] queue: list[uuid.UUID] = [fid] while queue: current_id = queue.pop(0) children_result = await db.execute( select(Folder).where( Folder.parent_id == current_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) for child in children_result.scalars().all(): all_folder_ids.append(child.id) queue.append(child.id) # Soft-delete all folders await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now)) # Soft-delete all files in those folders await db.execute( update(DmsFile) .where( DmsFile.tenant_id == tenant_id, DmsFile.folder_id.in_(all_folder_ids), DmsFile.deleted_at.is_(None), ) .values(deleted_at=now) ) await db.flush() return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Files ─── @router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=None, dependencies=[Depends(require_permission("dms:write"))]) async def upload_file( file: UploadFile = File(...), folder_id: str | None = Form(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC5: POST /api/v1/dms/files/upload → 201, file stored + metadata.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(folder_id, "folder_id") if folder_id else None # Check for blocked file types if _is_blocked_filetype(file.filename or ""): raise HTTPException( 400, detail={"detail": "File type not allowed", "code": "blocked_filetype"}, ) # MIME type validation: verify content_type against allowlist mime_type = file.content_type or "application/octet-stream" if not any(mime_type.startswith(prefix) for prefix in ALLOWED_MIME_PREFIXES): raise HTTPException( 400, detail={"detail": f"MIME type '{mime_type}' not allowed", "code": "blocked_mimetype"}, ) # Validate folder exists if specified if fid is not None: folder_result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) # Stream file to storage — avoid loading entire file into RAM import hashlib CHUNK_SIZE = 1024 * 1024 # 1MB chunks sha256 = hashlib.sha256() file_size = 0 async def chunk_stream(): nonlocal file_size while True: chunk = await file.read(CHUNK_SIZE) if not chunk: break file_size += len(chunk) if file_size > MAX_FILE_SIZE: raise HTTPException( 413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"} ) sha256.update(chunk) yield chunk # ── Hook: dms.before_upload (Filter) — can modify filename ── from app.core.hooks import apply_filters upload_data = { "filename": file.filename or "unnamed", "mime_type": file.content_type or "application/octet-stream", } upload_data = await apply_filters("dms.before_upload", upload_data) # Create file record file_id = uuid.uuid4() storage_path = _file_storage_path(tenant_id, file_id) # Save file via storage backend (true streaming, no RAM accumulation) storage = get_storage_backend() await storage.save_stream(storage_path, chunk_stream()) content_hash = sha256.hexdigest() mime_type = upload_data["mime_type"] # Tenant-local deduplication: check for existing file with same content_hash existing_q = await db.execute( select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.content_hash == content_hash, DmsFile.deleted_at.is_(None), ).limit(1) ) existing_file = existing_q.scalar_one_or_none() if existing_file: # Deduplicate: reuse existing file, remove the duplicate we just saved await storage.delete(storage_path) dms_file = existing_file else: dms_file = DmsFile( id=file_id, tenant_id=tenant_id, name=upload_data["filename"], folder_id=fid, uploaded_by=user_id, mime_type=mime_type, size_bytes=file_size, storage_path=storage_path, content_hash=content_hash, ) db.add(dms_file) await db.flush() return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "content_hash": dms_file.content_hash, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.get("/files/{file_id}", dependencies=[Depends(require_permission("dms:read"))]) async def get_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC6: GET /api/v1/dms/files/{id} → 200 + file metadata.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.get("/files", dependencies=[Depends(require_permission("dms:read"))]) async def list_all_files( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all non-deleted files for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) files = result.scalars().all() return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "deleted_at": None, "created_at": f.created_at.isoformat() if f.created_at else None, "updated_at": f.updated_at.isoformat() if f.updated_at else None, } for f in files ] @router.get("/folders/{folder_id}/files", dependencies=[Depends(require_permission("dms:read"))]) async def list_files_in_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all non-deleted files in a specific folder (non-recursive).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(folder_id, "folder_id") # Validate folder exists folder_result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.folder_id == fid, DmsFile.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) files = result.scalars().all() return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "deleted_at": None, "created_at": f.created_at.isoformat() if f.created_at else None, "updated_at": f.updated_at.isoformat() if f.updated_at else None, } for f in files ] @router.patch("/files/{file_id}", dependencies=[Depends(require_permission("dms:write"))]) async def update_file( file_id: str, body: FileUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) data = body.model_dump(exclude_unset=True) if "name" in data and data["name"] is not None: dms_file.name = data["name"] if "folder_id" in data: new_folder = _parse_uuid(data["folder_id"], "folder_id") if data["folder_id"] else None if new_folder is not None: folder_result = await db.execute( select(Folder).where( Folder.id == new_folder, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) dms_file.folder_id = new_folder await db.flush() await db.refresh(dms_file) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))]) async def delete_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) from datetime import UTC, datetime dms_file.deleted_at = datetime.now(UTC) await db.flush() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/files/{file_id}/restore", dependencies=[Depends(require_permission("dms:write"))]) async def restore_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_not(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) dms_file.deleted_at = None await db.flush() await db.refresh(dms_file) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } # ─── Preview & Edit ─── @router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))]) async def preview_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) if dms_file.mime_type != "application/pdf": raise HTTPException( 400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"} ) storage = get_storage_backend() if not await storage.exists(dms_file.storage_path): raise HTTPException( 404, detail={"detail": "File not found on disk", "code": "file_missing"} ) # Stream file directly from storage without loading into RAM from fastapi.responses import FileResponse as FastApiFileResponse import os as _os if isinstance(storage, LocalStorage): # LocalStorage: use FileResponse for automatic streaming full_path = storage._full_path(dms_file.storage_path) return FastApiFileResponse( path=full_path, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'}, ) else: # S3 or other: fall back to read (TODO: implement S3 streaming) content = await storage.read(dms_file.storage_path) def _stream(): yield content return StreamingResponse( _stream(), media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'}, ) @router.get("/files/{file_id}/download", dependencies=[Depends(require_permission("dms:read"))]) async def download_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Download any file type — streams directly from storage without loading into RAM.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) storage = get_storage_backend() if not await storage.exists(dms_file.storage_path): raise HTTPException(404, detail={"detail": "File not found on disk", "code": "file_missing"}) from fastapi.responses import FileResponse as FastApiFileResponse if isinstance(storage, LocalStorage): full_path = storage._full_path(dms_file.storage_path) return FastApiFileResponse( path=full_path, media_type=dms_file.mime_type or "application/octet-stream", filename=dms_file.name, ) else: content = await storage.read(dms_file.storage_path) def _stream(): yield content return StreamingResponse( _stream(), media_type=dms_file.mime_type or "application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{dms_file.name}"'}, ) @router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))]) async def create_edit_session( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = current_user["user_id"] user_name = current_user.get("name", "Unknown") is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) ext = _get_file_extension(dms_file.name) if ext not in OFFICE_EXTENSIONS: raise HTTPException( 400, detail={ "detail": "Only Office files (docx, xlsx, pptx) are supported", "code": "not_office", }, ) file_type = OFFICE_EXTENSIONS[ext] download_url = f"/api/v1/dms/files/{fid}/preview" callback_url = f"/api/v1/dms/files/{fid}/callback" config = { "document": { "fileType": file_type, "key": str(uuid.uuid4()), "title": dms_file.name, "url": download_url, }, "editorConfig": { "mode": "edit", "callbackUrl": callback_url, "user": { "id": user_id, "name": user_name, }, }, } return config # ─── Internal Sharing ─── @router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))]) async def share_file( file_id: str, body: ShareRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") # Verify file exists file_result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) if file_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) created_perms: list[dict] = [] for uid_str in body.user_ids: uid = _parse_uuid(uid_str, "user_id") # Check if already exists existing = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == uid, Permission.access_level == body.access_level, ) ) if existing.scalar_one_or_none() is None: perm = Permission( tenant_id=tenant_id, file_id=fid, user_id=uid, group_id=None, access_level=body.access_level, ) db.add(perm) await db.flush() created_perms.append( { "id": str(perm.id), "file_id": str(fid), "user_id": str(uid), "group_id": None, "access_level": body.access_level, } ) for gid_str in body.group_ids: gid = _parse_uuid(gid_str, "group_id") existing = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.group_id == gid, Permission.access_level == body.access_level, ) ) if existing.scalar_one_or_none() is None: perm = Permission( tenant_id=tenant_id, file_id=fid, user_id=uuid.UUID(current_user["user_id"]), group_id=gid, access_level=body.access_level, ) db.add(perm) await db.flush() created_perms.append( { "id": str(perm.id), "file_id": str(fid), "user_id": str(perm.user_id), "group_id": str(gid), "access_level": body.access_level, } ) return { "file_id": str(fid), "shared_with": created_perms, "count": len(created_perms), } @router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))]) async def remove_share( file_id: str, body: ShareRemoveRequest = Body(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(file_id, "file_id") if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) if body.user_id: uid = _parse_uuid(body.user_id, "user_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == uid, ) ) perms = result.scalars().all() for p in perms: await db.delete(p) if body.group_id: gid = _parse_uuid(body.group_id, "group_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.group_id == gid, ) ) perms = result.scalars().all() for p in perms: await db.delete(p) await db.flush() return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Search & Bulk ─── @router.get("/search", dependencies=[Depends(require_permission("dms:read"))]) async def search_files( q: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), DmsFile.name.ilike(f"%{q}%"), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) files = result.scalars().all() return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "deleted_at": None, "created_at": f.created_at.isoformat() if f.created_at else None, } for f in files ] @router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))]) async def shared_with_me( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" # Query permissions for this user and join with files perm_result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.user_id == user_id, ) ) perms = perm_result.scalars().all() file_ids = {p.file_id for p in perms} if not file_ids: return {"items": [], "total": 0} query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) files = result.scalars().all() # Map permissions for access_level perm_map: dict[uuid.UUID, str] = {} for p in perms: if p.file_id in file_ids: perm_map[p.file_id] = p.access_level return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "access_level": perm_map.get(f.id, "read"), "created_at": f.created_at.isoformat() if f.created_at else None, } for f in files ] @router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))]) async def bulk_move( body: BulkMoveRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC18: POST /api/v1/dms/files/bulk-move → 200, files moved.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" target_folder_id = ( _parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None ) # Validate target folder if specified if target_folder_id is not None: folder_result = await db.execute( select(Folder).where( Folder.id == target_folder_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Target folder not found", "code": "not_found"} ) file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) files = result.scalars().all() moved_count = 0 for f in files: f.folder_id = target_folder_id moved_count += 1 await db.flush() return { "moved": moved_count, "file_ids": [str(fid) for fid in file_ids], "target_folder_id": str(target_folder_id) if target_folder_id else None, } @router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))]) async def bulk_delete( body: BulkDeleteRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] from datetime import UTC, datetime now = datetime.now(UTC) # Apply visibility filter to only delete files user has access to query = select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin ) result = await db.execute(query) accessible_files = result.scalars().all() accessible_ids = [f.id for f in accessible_files] result = await db.execute( update(DmsFile) .where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(accessible_ids), DmsFile.deleted_at.is_(None), ) .values(deleted_at=now) ) deleted_count = result.rowcount await db.flush() return { "deleted": deleted_count, "file_ids": body.file_ids, }