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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user