Files
leocrm/app/plugins/builtins/kommunikation/dms_bridge.py
T
Agent Zero 727d86614e Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
2026-07-25 21:03:46 +02:00

193 lines
5.8 KiB
Python

"""DMS Bridge — integrates with the DMS plugin for file storage."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any
import aiofiles
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.dms.contracts import get_contract as get_dms_contract
_dms = get_dms_contract()
DmsFile = _dms.DmsFile
Folder = _dms.Folder
logger = logging.getLogger(__name__)
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
COMM_FOLDER_NAME = "_kommunikation"
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
class DmsBridge:
"""Bridge to the DMS plugin for attachment storage."""
@staticmethod
async def ensure_comm_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> Folder:
"""Ensure the _kommunikation root folder exists in DMS."""
result = await db.execute(
select(Folder).where(
Folder.name == COMM_FOLDER_NAME,
Folder.parent_id.is_(None),
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=COMM_FOLDER_NAME,
parent_id=None,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def ensure_conversation_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
conversation_id: uuid.UUID,
) -> Folder:
"""Ensure a sub-folder for a specific conversation exists."""
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
conv_name = str(conversation_id)
result = await db.execute(
select(Folder).where(
Folder.name == conv_name,
Folder.parent_id == root_folder.id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=conv_name,
parent_id=root_folder.id,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def store_attachment(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
file: UploadFile,
) -> dict[str, Any]:
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
Returns dict with file_id, file_name, file_type, file_size.
"""
# Check size limit
content = await file.read()
file_size = len(content)
if file_size > MAX_DIRECT_UPLOAD:
raise ValueError(
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
f"Use DMS reference instead."
)
# Ensure conversation folder
folder = await DmsBridge.ensure_conversation_folder(
db, tenant_id, user_id, conversation_id
)
# Save file to disk
file_id = uuid.uuid4()
file_ext = os.path.splitext(file.filename or "")[1] or ""
storage_path = os.path.join(
DMS_STORAGE_BASE,
str(tenant_id),
str(folder.id),
f"{file_id}{file_ext}",
)
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
async with aiofiles.open(storage_path, "wb") as f:
await f.write(content)
# Create DMS file record
dms_file = DmsFile(
tenant_id=tenant_id,
name=file.filename or f"{file_id}",
folder_id=folder.id,
uploaded_by=user_id,
mime_type=file.content_type or "application/octet-stream",
size_bytes=file_size,
storage_path=storage_path,
)
db.add(dms_file)
await db.flush()
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "comm",
}
@staticmethod
async def reference_external_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Reference an existing DMS file without copying it.
Returns dict with file metadata or None if file not found.
"""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
return None
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "dms",
}
@staticmethod
async def get_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> DmsFile | None:
"""Get a DMS file by ID."""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()