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
+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