Phase 0 Complete: Tasks 0.7-0.20
- 0.7: UI-Design-Richtlinien (docs/ui-design-guidelines.md, 535 lines) - 0.8: Theme-Customization Backend (4 theme fields, migration 0023) - 0.9: Theme-Customization Frontend (SettingsTheme.tsx, themeStore.ts, live preview) - 0.10: RBAC-Audit (4 plugins secured, 53 routes with require_permission) - 0.11: LiteLLM-Cleanup (llm_client.py migrated from httpx to litellm) - 0.12: KI-Agent-Framework docs (plugin-development-guide.md, agent_capabilities field) - 0.13: Heartbeat configurable (ProactiveSettings, migration 0024, frontend UI) - 0.14: Unified Search Field-Level RBAC (resolve_permissions + filter_fields_by_permission) - 0.15: Undo/History-System (EntityHistory model, service, routes, migration 0025, HistoryViewer) - 0.16: Storage Backend (LocalStorage + S3Storage, DMS/attachments/mail updated) - 0.17: Import/Export unified Contact fields (firstname, surname, email_1, phone_1) - 0.18: .gitignore & Config-Cleanup (webui→frontend, python-jose removed, .env untracked) - 0.19: Mail-Salt Security-Fix (per-account random salt, migration 0026) - 0.20: AGPL replaced (PyMuPDF→pypdf, OnlyOffice→Collabora, LICENSE + THIRD_PARTY_LICENSES.md)
This commit is contained in:
@@ -383,10 +383,10 @@ async def _extract_attachment_content(
|
||||
text_content = content.decode("utf-8", errors="replace")
|
||||
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(stream=content, filetype="pdf")
|
||||
text_content = "\n".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
from pypdf import PdfReader
|
||||
from io import BytesIO
|
||||
reader = PdfReader(BytesIO(content))
|
||||
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
text_content = f"[PDF file: {att.filename} - extraction not available]"
|
||||
elif mime.startswith("image/"):
|
||||
|
||||
@@ -322,8 +322,9 @@ async def deep_analysis(
|
||||
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
"""Heartbeat job for the AI Proactive plugin.
|
||||
|
||||
Runs every 5 minutes (scheduled by the plugin on activation).
|
||||
Posts a status message to the 'Live KI' room in the kommunikation system.
|
||||
Interval is configurable via ProactiveSettings.heartbeat_interval_seconds.
|
||||
Target room is configurable via ProactiveSettings.heartbeat_target_room.
|
||||
Posts a status message to the configured room in the kommunikation system.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
@@ -340,13 +341,33 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
)
|
||||
|
||||
async with create_db_session(tid) as db:
|
||||
# Create or get the 'Live KI' room for this user
|
||||
# Check if heartbeat is enabled and get configuration
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
settings_result = await db.execute(
|
||||
sa_select(ProactiveSettings)
|
||||
.where(ProactiveSettings.tenant_id == tid)
|
||||
.where(ProactiveSettings.user_id == uid)
|
||||
.limit(1)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
# If no settings or heartbeat disabled, skip
|
||||
if settings is not None and not settings.heartbeat_enabled:
|
||||
logger.debug("heartbeat: disabled for user %s", user_id)
|
||||
return
|
||||
|
||||
# Get target room name from settings or use default
|
||||
target_room_title = settings.heartbeat_target_room if settings else "Live KI"
|
||||
|
||||
# Create or get the target room for this user
|
||||
room = await create_plugin_room(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
plugin_name="ai_proactive",
|
||||
title="Live KI",
|
||||
title=target_room_title,
|
||||
participant_type="ai_proactive",
|
||||
user_role="member",
|
||||
)
|
||||
|
||||
@@ -109,3 +109,7 @@ class ProactiveSettings(Base, TenantMixin):
|
||||
Integer, nullable=False, default=10
|
||||
)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False, default="ollama/deepseek-v4-flash")
|
||||
# Heartbeat configuration
|
||||
heartbeat_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
heartbeat_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
|
||||
heartbeat_target_room: Mapped[str] = mapped_column(String(200), nullable=False, default="Live KI")
|
||||
|
||||
@@ -72,6 +72,9 @@ def _settings_to_response(s: ProactiveSettings) -> SettingsResponse:
|
||||
confidence_threshold=s.confidence_threshold,
|
||||
rate_limit_seconds=s.rate_limit_seconds,
|
||||
model=s.model,
|
||||
heartbeat_enabled=s.heartbeat_enabled,
|
||||
heartbeat_interval_seconds=s.heartbeat_interval_seconds,
|
||||
heartbeat_target_room=s.heartbeat_target_room,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ class SettingsResponse(BaseModel):
|
||||
confidence_threshold: float
|
||||
rate_limit_seconds: int
|
||||
model: str
|
||||
heartbeat_enabled: bool = True
|
||||
heartbeat_interval_seconds: int = 300
|
||||
heartbeat_target_room: str = "Live KI"
|
||||
available_models: list[str] = Field(default_factory=lambda: [
|
||||
'ollama/deepseek-v4-flash',
|
||||
'ollama/deepseek-v4-pro',
|
||||
@@ -90,6 +93,9 @@ class SettingsUpdate(BaseModel):
|
||||
confidence_threshold: float | None = None
|
||||
rate_limit_seconds: int | None = None
|
||||
model: str | None = None
|
||||
heartbeat_enabled: bool | None = None
|
||||
heartbeat_interval_seconds: int | None = None
|
||||
heartbeat_target_room: str | None = None
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
|
||||
@@ -114,6 +114,9 @@ async def get_user_settings(
|
||||
confidence_threshold=0.5,
|
||||
rate_limit_seconds=10,
|
||||
model="ollama/deepseek-v4-flash",
|
||||
heartbeat_enabled=True,
|
||||
heartbeat_interval_seconds=300,
|
||||
heartbeat_target_room="Live KI",
|
||||
)
|
||||
db.add(settings)
|
||||
await db.flush()
|
||||
|
||||
@@ -34,5 +34,11 @@ class CalendarPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"calendar:delete",
|
||||
"calendar:share",
|
||||
"calendar:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.deps import get_current_user, require_admin, require_permission
|
||||
from app.plugins.builtins.calendar.ics_utils import (
|
||||
export_entries_to_ics,
|
||||
ics_events_to_entry_data,
|
||||
@@ -163,7 +163,7 @@ async def _check_write_permission(
|
||||
# ─── Calendar CRUD ───
|
||||
|
||||
|
||||
@calendar_router.get("")
|
||||
@calendar_router.get("", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -180,7 +180,7 @@ async def list_calendars(
|
||||
return [_calendar_to_dict(c) for c in cals]
|
||||
|
||||
|
||||
@calendar_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@calendar_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_calendar(
|
||||
body: CalendarCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -201,7 +201,7 @@ async def create_calendar(
|
||||
return _calendar_to_dict(cal)
|
||||
|
||||
|
||||
@calendar_router.patch("/{calendar_id}")
|
||||
@calendar_router.patch("/{calendar_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_calendar(
|
||||
calendar_id: str,
|
||||
body: CalendarUpdate,
|
||||
@@ -226,7 +226,7 @@ async def update_calendar(
|
||||
return _calendar_to_dict(cal)
|
||||
|
||||
|
||||
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
|
||||
async def delete_calendar(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -259,7 +259,7 @@ async def delete_calendar(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@calendar_router.post("/{calendar_id}/share")
|
||||
@calendar_router.post("/{calendar_id}/share", dependencies=[Depends(require_permission("calendar:share"))])
|
||||
async def share_calendar(
|
||||
calendar_id: str,
|
||||
body: ShareRequest,
|
||||
@@ -297,7 +297,7 @@ async def share_calendar(
|
||||
}
|
||||
|
||||
|
||||
@calendar_router.get("/{calendar_id}/permissions")
|
||||
@calendar_router.get("/{calendar_id}/permissions", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def get_permissions(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -324,7 +324,7 @@ async def get_permissions(
|
||||
# ─── Entries ───
|
||||
|
||||
|
||||
@router.get("/calendar/entries")
|
||||
@router.get("/calendar/entries", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_entries(
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
@@ -385,7 +385,7 @@ async def list_entries(
|
||||
return all_occurrences
|
||||
|
||||
|
||||
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_entry(
|
||||
body: EntryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -458,7 +458,7 @@ async def create_entry(
|
||||
return _entry_to_dict(entry)
|
||||
|
||||
|
||||
@router.get("/calendar/entries/export")
|
||||
@router.get("/calendar/entries/export", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def export_entries(
|
||||
format: str = "csv",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -519,7 +519,7 @@ async def export_entries(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar/entries/{entry_id}")
|
||||
@router.get("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def get_entry(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -555,7 +555,7 @@ async def get_entry(
|
||||
return _entry_to_dict(entry, links, subtasks)
|
||||
|
||||
|
||||
@router.patch("/calendar/entries/{entry_id}")
|
||||
@router.patch("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_entry(
|
||||
entry_id: str,
|
||||
body: EntryUpdate,
|
||||
@@ -611,7 +611,7 @@ async def update_entry(
|
||||
return _entry_to_dict(entry)
|
||||
|
||||
|
||||
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
|
||||
async def delete_entry(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -631,7 +631,7 @@ async def delete_entry(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/link")
|
||||
@router.post("/calendar/entries/{entry_id}/link", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def link_entry(
|
||||
entry_id: str,
|
||||
body: LinkRequest,
|
||||
@@ -658,7 +658,7 @@ async def link_entry(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_subtask(
|
||||
entry_id: str,
|
||||
body: SubtaskCreate,
|
||||
@@ -684,7 +684,7 @@ async def create_subtask(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}")
|
||||
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def update_subtask(
|
||||
entry_id: str,
|
||||
sub_id: str,
|
||||
@@ -717,7 +717,7 @@ async def update_subtask(
|
||||
# ─── Bulk + Kanban + Export ───
|
||||
|
||||
|
||||
@router.post("/calendar/entries/bulk")
|
||||
@router.post("/calendar/entries/bulk", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def bulk_action(
|
||||
body: BulkAction,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -751,7 +751,7 @@ async def bulk_action(
|
||||
return {"action": body.action, "affected": len(entry_ids)}
|
||||
|
||||
|
||||
@router.get("/calendar/kanban")
|
||||
@router.get("/calendar/kanban", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def kanban_board(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -783,7 +783,7 @@ async def kanban_board(
|
||||
# ─── ICS Feed + Import ───
|
||||
|
||||
|
||||
@router.get("/calendar/{calendar_id}/ics-feed")
|
||||
@router.get("/calendar/{calendar_id}/ics-feed", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def ics_feed(
|
||||
calendar_id: str,
|
||||
token: str | None = None,
|
||||
@@ -820,7 +820,7 @@ async def ics_feed(
|
||||
return Response(content=ics_content, media_type="text/calendar")
|
||||
|
||||
|
||||
@router.post("/calendar/import")
|
||||
@router.post("/calendar/import", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def import_ics(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: str | None = None,
|
||||
@@ -879,7 +879,7 @@ async def import_ics(
|
||||
# ─── Resources ───
|
||||
|
||||
|
||||
@resource_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@resource_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def create_resource(
|
||||
body: ResourceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -897,7 +897,7 @@ async def create_resource(
|
||||
return {"id": str(resource.id), "name": resource.name, "type": resource.type}
|
||||
|
||||
|
||||
@router.post("/calendar/entries/{entry_id}/book-resource")
|
||||
@router.post("/calendar/entries/{entry_id}/book-resource", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def book_resource(
|
||||
entry_id: str,
|
||||
body: BookResourceRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DMS plugin — folders, files, preview, OnlyOffice, internal sharing, search, bulk ops."""
|
||||
"""DMS plugin — folders, files, preview, Collabora, internal sharing, search, bulk ops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +13,7 @@ class DmsPlugin(BasePlugin):
|
||||
name="dms",
|
||||
version="1.0.0",
|
||||
display_name="DMS",
|
||||
description="Document management: folder hierarchy, file upload, PDF preview, OnlyOffice edit sessions, internal sharing, search, bulk ops.",
|
||||
description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
|
||||
dependencies=["permissions"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
@@ -24,5 +24,11 @@ class DmsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"dms:read",
|
||||
"dms:write",
|
||||
"dms:delete",
|
||||
"dms:share",
|
||||
"dms:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DMS plugin routes — folders, files, preview, OnlyOffice, internal sharing, search, bulk."""
|
||||
"""DMS plugin routes — folders, files, preview, Collabora, internal sharing, search, bulk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,7 +21,8 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.core.storage import get_storage_backend
|
||||
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 (
|
||||
@@ -37,10 +38,7 @@ from app.plugins.builtins.permissions.models import Permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
|
||||
|
||||
# Configurable storage base path
|
||||
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
||||
|
||||
# Office file extensions mapped to OnlyOffice file types
|
||||
# Office file extensions mapped to Collabora file types
|
||||
OFFICE_EXTENSIONS = {
|
||||
".docx": "docx",
|
||||
".xlsx": "xlsx",
|
||||
@@ -61,8 +59,8 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
|
||||
|
||||
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
|
||||
"""Build on-disk storage path for a file."""
|
||||
return os.path.join(DMS_STORAGE_BASE, str(tenant_id), str(file_id))
|
||||
"""Build relative storage path for a file (relative to storage base)."""
|
||||
return f"{tenant_id}/{file_id}"
|
||||
|
||||
|
||||
def _get_file_extension(filename: str) -> str:
|
||||
@@ -73,7 +71,7 @@ def _get_file_extension(filename: str) -> str:
|
||||
# ─── Folders ───
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -142,7 +140,7 @@ async def list_folders(
|
||||
return root_nodes
|
||||
|
||||
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED)
|
||||
@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),
|
||||
@@ -221,7 +219,7 @@ async def create_folder(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}")
|
||||
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
body: FolderUpdate,
|
||||
@@ -334,7 +332,7 @@ async def update_folder(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@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),
|
||||
@@ -396,7 +394,7 @@ async def delete_folder(
|
||||
# ─── Files ───
|
||||
|
||||
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: str | None = Form(None),
|
||||
@@ -433,10 +431,9 @@ async def upload_file(
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = _file_storage_path(tenant_id, file_id)
|
||||
|
||||
# Ensure directory exists and write file
|
||||
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||
with open(storage_path, "wb") as f: # noqa: ASYNC230
|
||||
f.write(content)
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, content)
|
||||
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
@@ -467,7 +464,7 @@ async def upload_file(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files/{file_id}")
|
||||
@router.get("/files/{file_id}", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def get_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -502,7 +499,7 @@ async def get_file(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
@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),
|
||||
@@ -534,7 +531,7 @@ async def list_all_files(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}/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),
|
||||
@@ -580,7 +577,7 @@ async def list_files_in_folder(
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/files/{file_id}")
|
||||
@router.patch("/files/{file_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_file(
|
||||
file_id: str,
|
||||
body: FileUpdate,
|
||||
@@ -638,7 +635,7 @@ async def update_file(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@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),
|
||||
@@ -666,7 +663,7 @@ async def delete_file(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/restore")
|
||||
@router.post("/files/{file_id}/restore", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def restore_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -708,7 +705,7 @@ async def restore_file(
|
||||
# ─── Preview & Edit ───
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/preview")
|
||||
@router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def preview_file(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -734,14 +731,16 @@ async def preview_file(
|
||||
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
||||
)
|
||||
|
||||
if not os.path.exists(dms_file.storage_path):
|
||||
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"}
|
||||
)
|
||||
|
||||
content = await storage.read(dms_file.storage_path)
|
||||
|
||||
def _stream():
|
||||
with open(dms_file.storage_path, "rb") as f:
|
||||
yield from iter(lambda: f.read(65536), b"")
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
@@ -750,13 +749,13 @@ async def preview_file(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/edit-session")
|
||||
@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 + OnlyOffice config."""
|
||||
"""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")
|
||||
@@ -810,7 +809,7 @@ async def create_edit_session(
|
||||
# ─── Internal Sharing ───
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/share")
|
||||
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def share_file(
|
||||
file_id: str,
|
||||
body: ShareRequest,
|
||||
@@ -902,7 +901,7 @@ async def share_file(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@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(...),
|
||||
@@ -946,7 +945,7 @@ async def remove_share(
|
||||
# ─── Search & Bulk ───
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def search_files(
|
||||
q: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -979,7 +978,7 @@ async def search_files(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shared-with-me")
|
||||
@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),
|
||||
@@ -1031,7 +1030,7 @@ async def shared_with_me(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/files/bulk-move")
|
||||
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def bulk_move(
|
||||
body: BulkMoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -1082,7 +1081,7 @@ async def bulk_move(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/bulk-delete")
|
||||
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def bulk_delete(
|
||||
body: BulkDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -65,6 +65,6 @@ class BulkDeleteRequest(BaseModel):
|
||||
file_ids: list[str] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class OnlyOfficeConfig(BaseModel):
|
||||
class CollaboraConfig(BaseModel):
|
||||
document: dict
|
||||
editorConfig: dict # noqa: N815
|
||||
|
||||
@@ -36,7 +36,11 @@ class EntityLinksPlugin(BasePlugin):
|
||||
],
|
||||
events=["company.deleted", "contact.deleted"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"entity_links:read",
|
||||
"entity_links:write",
|
||||
"entity_links:delete",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.entity_links.models import EntityLink
|
||||
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
|
||||
|
||||
@@ -29,7 +29,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/link")
|
||||
@router.post("/files/{file_id}/link", dependencies=[Depends(require_permission("entity_links:write"))])
|
||||
async def link_file_to_entity(
|
||||
file_id: str,
|
||||
body: EntityLinkRequest,
|
||||
@@ -84,7 +84,7 @@ async def link_file_to_entity(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("entity_links:delete"))])
|
||||
async def unlink_file_from_entity(
|
||||
file_id: str,
|
||||
body: EntityLinkRequest = Body(...),
|
||||
@@ -112,7 +112,7 @@ async def unlink_file_from_entity(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/links")
|
||||
@router.get("/files/{file_id}/links", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_file_links(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -140,7 +140,7 @@ async def list_file_links(
|
||||
]
|
||||
|
||||
|
||||
@company_router.get("/{company_id}/files")
|
||||
@company_router.get("/{company_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_company_files(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -169,7 +169,7 @@ async def list_company_files(
|
||||
]
|
||||
|
||||
|
||||
@contact_router.get("/{contact_id}/files")
|
||||
@contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
|
||||
async def list_contact_files(
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -47,6 +47,7 @@ class MailAccount(Base, TenantMixin):
|
||||
smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
password_salt: Mapped[str] = mapped_column(String(64), nullable=False, default="") # base64-encoded random salt
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -8,9 +8,9 @@ so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.deps import require_permission
|
||||
from app.plugins.builtins.mail.models import (
|
||||
ContactPgpKey,
|
||||
@@ -109,32 +110,31 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
|
||||
async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
|
||||
"""Resolve temporary attachment upload IDs to file paths on disk.
|
||||
|
||||
Each uploaded attachment is stored under
|
||||
``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the
|
||||
directory for the single file inside and return its metadata.
|
||||
"""
|
||||
import os
|
||||
|
||||
resolved: list[dict] = []
|
||||
base_dir = os.environ.get("STORAGE_PATH", "/tmp")
|
||||
storage = get_storage_backend()
|
||||
for att_id in attachment_ids:
|
||||
upload_dir = os.path.join(base_dir, "mail_uploads", att_id)
|
||||
if not os.path.isdir(upload_dir):
|
||||
prefix = f"mail_uploads/{att_id}/"
|
||||
files = await storage.list_files(prefix)
|
||||
if not files:
|
||||
continue
|
||||
for fname in os.listdir(upload_dir):
|
||||
fpath = os.path.join(upload_dir, fname)
|
||||
if os.path.isfile(fpath):
|
||||
resolved.append(
|
||||
{
|
||||
"path": fpath,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
for fpath in files:
|
||||
fname = os.path.basename(fpath)
|
||||
abs_path = await storage.get_url(fpath)
|
||||
resolved.append(
|
||||
{
|
||||
"path": abs_path,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -752,19 +752,10 @@ async def upload_attachment(
|
||||
safe_filename = _sanitize_filename(file.filename or "attachment")
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
# Store in a temp directory keyed by the temp_id
|
||||
import os
|
||||
|
||||
temp_dir = os.path.join(
|
||||
os.environ.get("STORAGE_PATH", "/tmp"), "mail_uploads", temp_id
|
||||
)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
file_path = os.path.join(temp_dir, safe_filename)
|
||||
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(content)
|
||||
# Store via storage backend in a path keyed by the temp_id
|
||||
storage = get_storage_backend()
|
||||
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
|
||||
await storage.save(file_path, content)
|
||||
|
||||
return {
|
||||
"id": temp_id,
|
||||
@@ -828,7 +819,7 @@ async def send_mail(
|
||||
in_reply_to=data.in_reply_to,
|
||||
references_header=data.references_header,
|
||||
signature=signature,
|
||||
attachment_paths=_resolve_attachment_paths(data.attachments),
|
||||
attachment_paths=await _resolve_attachment_paths(data.attachments),
|
||||
)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
@@ -1423,19 +1414,16 @@ async def download_attachment(
|
||||
).scalar_one_or_none()
|
||||
if not attachment:
|
||||
raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"})
|
||||
if not Path(attachment.storage_path).exists():
|
||||
storage = get_storage_backend()
|
||||
if not await storage.exists(attachment.storage_path):
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "File not found on disk", "code": "file_missing"}
|
||||
)
|
||||
import aiofiles
|
||||
|
||||
content = await storage.read(attachment.storage_path)
|
||||
|
||||
async def file_stream():
|
||||
async with aiofiles.open(attachment.storage_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
file_stream(),
|
||||
|
||||
@@ -134,9 +134,12 @@ def attachment_to_response(att: MailAttachment) -> dict:
|
||||
|
||||
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024")
|
||||
|
||||
# Legacy salt for backward compatibility with existing encrypted passwords
|
||||
_LEGACY_SALT = b"leocrm-mail-salt"
|
||||
|
||||
def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
|
||||
"""Derive a 32-byte Fernet key from a password using PBKDF2."""
|
||||
|
||||
def _derive_key(password: str, salt: bytes) -> bytes:
|
||||
"""Derive a 32-byte Fernet key from a password using PBKDF2 with the given salt."""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
@@ -146,17 +149,39 @@ def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
|
||||
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
|
||||
|
||||
_fernet = Fernet(_derive_key(MAIL_ENCRYPTION_KEY))
|
||||
def generate_salt() -> str:
|
||||
"""Generate a random 32-byte salt and return as base64 string."""
|
||||
salt = os.urandom(32)
|
||||
return base64.urlsafe_b64encode(salt).decode()
|
||||
|
||||
|
||||
def encrypt_password(plaintext: str) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext."""
|
||||
return _fernet.encrypt(plaintext.encode()).decode()
|
||||
def _get_fernet(salt_b64: str | None = None) -> Fernet:
|
||||
"""Get a Fernet instance. If salt_b64 is provided, use it; otherwise use legacy salt."""
|
||||
if salt_b64:
|
||||
salt = base64.urlsafe_b64decode(salt_b64.encode())
|
||||
else:
|
||||
salt = _LEGACY_SALT
|
||||
return Fernet(_derive_key(MAIL_ENCRYPTION_KEY, salt))
|
||||
|
||||
|
||||
def decrypt_password(ciphertext: str) -> str:
|
||||
"""Decrypt a password encrypted with encrypt_password."""
|
||||
return _fernet.decrypt(ciphertext.encode()).decode()
|
||||
def encrypt_password(plaintext: str, salt_b64: str | None = None) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.
|
||||
|
||||
If salt_b64 is provided, uses that salt for key derivation.
|
||||
If not, uses the legacy hardcoded salt (for backward compatibility).
|
||||
"""
|
||||
fernet = _get_fernet(salt_b64)
|
||||
return fernet.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_password(ciphertext: str, salt_b64: str | None = None) -> str:
|
||||
"""Decrypt a password encrypted with encrypt_password.
|
||||
|
||||
If salt_b64 is provided, uses that salt for key derivation.
|
||||
If not, uses the legacy hardcoded salt (for backward compatibility).
|
||||
"""
|
||||
fernet = _get_fernet(salt_b64)
|
||||
return fernet.decrypt(ciphertext.encode()).decode()
|
||||
|
||||
|
||||
# ─── HTML Sanitization (F-MAIL: no script tags) ───
|
||||
@@ -255,6 +280,7 @@ async def create_mail_account(
|
||||
db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||
) -> MailAccount:
|
||||
"""Create a new mail account with encrypted password."""
|
||||
salt = generate_salt()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -267,7 +293,8 @@ async def create_mail_account(
|
||||
smtp_port=data.get("smtp_port", 587),
|
||||
smtp_tls=data.get("smtp_tls", True),
|
||||
username=data.get("username") or data["email_address"],
|
||||
encrypted_password=encrypt_password(data["password"]),
|
||||
encrypted_password=encrypt_password(data["password"], salt),
|
||||
password_salt=salt,
|
||||
is_shared=data.get("is_shared", False),
|
||||
is_active=True,
|
||||
sent_folder_imap_name=data.get("sent_folder_imap_name"),
|
||||
@@ -333,15 +360,20 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict
|
||||
if api_field in data and data[api_field] is not None:
|
||||
setattr(account, model_field, data[api_field])
|
||||
if "password" in data and data["password"] is not None:
|
||||
account.encrypted_password = encrypt_password(data["password"])
|
||||
new_salt = generate_salt()
|
||||
account.password_salt = new_salt
|
||||
account.encrypted_password = encrypt_password(data["password"], new_salt)
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account_password(account: MailAccount) -> str:
|
||||
"""Decrypt and return the account password (internal use only)."""
|
||||
return decrypt_password(account.encrypted_password)
|
||||
"""Decrypt and return the account password (internal use only).
|
||||
|
||||
Uses per-account salt if available, falls back to legacy salt for old accounts.
|
||||
"""
|
||||
return decrypt_password(account.encrypted_password, account.password_salt or None)
|
||||
|
||||
|
||||
def account_to_response(account: MailAccount) -> dict:
|
||||
|
||||
@@ -24,6 +24,11 @@ class TagsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"tags:read",
|
||||
"tags:write",
|
||||
"tags:delete",
|
||||
"tags:admin",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||
from app.plugins.builtins.tags.schemas import (
|
||||
TagAssignRequest,
|
||||
@@ -33,7 +33,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", dependencies=[Depends(require_permission("tags:read"))])
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -71,7 +71,7 @@ async def list_tags(
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def create_tag(
|
||||
body: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -98,7 +98,7 @@ async def create_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
@router.patch("/{tag_id}", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def update_tag(
|
||||
tag_id: str,
|
||||
body: TagUpdate,
|
||||
@@ -138,7 +138,7 @@ async def update_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assign")
|
||||
@router.post("/assign", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def assign_tag(
|
||||
body: TagAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -194,7 +194,7 @@ async def assign_tag(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
|
||||
async def unassign_tag(
|
||||
body: TagUnassignRequest = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -221,7 +221,7 @@ async def unassign_tag(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
|
||||
async def delete_tag(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -246,7 +246,7 @@ async def delete_tag(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/bulk-assign")
|
||||
@router.post("/bulk-assign", dependencies=[Depends(require_permission("tags:write"))])
|
||||
async def bulk_assign_tags(
|
||||
body: TagBulkAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -303,7 +303,7 @@ async def bulk_assign_tags(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tag_id}/entities")
|
||||
@router.get("/{tag_id}/entities", dependencies=[Depends(require_permission("tags:read"))])
|
||||
async def list_tag_entities(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.jobs import enqueue_job
|
||||
from app.core.permissions import resolve_permissions, filter_fields_by_permission
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
from app.plugins.builtins.unified_search.query_understanding import (
|
||||
@@ -49,6 +50,7 @@ async def search(
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search with KI query understanding."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# KI query understanding
|
||||
query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id)
|
||||
@@ -62,6 +64,18 @@ async def search(
|
||||
limit=req.limit,
|
||||
)
|
||||
|
||||
# Resolve user permissions for field-level RBAC
|
||||
resolved_perms = await resolve_permissions(db, user_id, tenant_id)
|
||||
|
||||
# Map entity_type to module name for field-level permissions
|
||||
_ENTITY_TO_MODULE = {
|
||||
"contact": "contacts",
|
||||
"company": "contacts",
|
||||
"mail": "mail",
|
||||
"file": "dms",
|
||||
"event": "calendar",
|
||||
}
|
||||
|
||||
# KI result aggregation
|
||||
aggregation = await llm_aggregate_results(results, req.query, db=db, tenant_id=tenant_id)
|
||||
|
||||
@@ -72,7 +86,11 @@ async def search(
|
||||
title=r.get("title", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
score=r.get("score", 0.0),
|
||||
data=r.get("data", {}),
|
||||
data=filter_fields_by_permission(
|
||||
r.get("data", {}),
|
||||
resolved_perms,
|
||||
_ENTITY_TO_MODULE.get(r.get("entity_type", ""), r.get("entity_type", "")),
|
||||
),
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ async def extract_text_from_file(file_path: str, mime_type: str) -> str:
|
||||
"""Extract text content from a file based on its MIME type.
|
||||
|
||||
Supports:
|
||||
- PDF (via PyMuPDF/fitz)
|
||||
- PDF (via pypdf)
|
||||
- DOCX (via python-docx)
|
||||
- XLSX (via openpyxl)
|
||||
- PPTX (via python-pptx)
|
||||
@@ -57,14 +57,15 @@ def _truncate(text: str) -> str:
|
||||
|
||||
|
||||
async def _extract_pdf(file_path: str) -> str:
|
||||
"""Extract text from PDF using PyMuPDF (fitz)."""
|
||||
import fitz # PyMuPDF
|
||||
"""Extract text from PDF using pypdf (BSD-licensed)."""
|
||||
from pypdf import PdfReader
|
||||
|
||||
doc = fitz.open(file_path)
|
||||
reader = PdfReader(file_path)
|
||||
parts: list[str] = []
|
||||
for page in doc:
|
||||
parts.append(page.get_text())
|
||||
doc.close()
|
||||
for page in reader.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user