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:
Agent Zero
2026-07-23 08:42:26 +02:00
parent 3d06cb2353
commit ec81940178
65 changed files with 3061 additions and 277 deletions
@@ -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/"):
+25 -4
View File
@@ -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()
+7 -1
View File
@@ -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 -22
View File
@@ -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,
+9 -3
View File
@@ -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",
],
)
+34 -35
View File
@@ -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),
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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,
)
+6 -6
View File
@@ -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),
+1
View File
@@ -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)
+28 -40
View File
@@ -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(),
+45 -13
View File
@@ -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:
+6 -1
View File
@@ -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 -9
View File
@@ -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),
+19 -1
View File
@@ -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))