|
4 | 4 | Endpoints for CRUD on projects, and upload/download/delete of |
5 | 5 | IFC, BCF, and IDS files within projects. Files are stored on disk |
6 | 6 | under PROJECT_FILES_DIR/{project_id}/{file_type}/. |
| 7 | +
|
| 8 | +Also provides .wefc envelope endpoints that bridge the persistent |
| 9 | +project (DB row) to its corresponding tenant Nextcloud folder. |
7 | 10 | """ |
8 | 11 |
|
| 12 | +import json |
9 | 13 | import logging |
10 | 14 | import os |
11 | 15 | import shutil |
12 | 16 | import uuid |
| 17 | +from datetime import datetime, timezone |
13 | 18 | from pathlib import Path |
| 19 | +from typing import Any |
14 | 20 |
|
15 | | -from fastapi import APIRouter, File, Form, HTTPException, UploadFile |
16 | | -from fastapi.responses import FileResponse |
| 21 | +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile |
| 22 | +from fastapi.responses import FileResponse, JSONResponse, Response |
17 | 23 | from sqlalchemy import select |
18 | 24 |
|
19 | 25 | from server.database import get_session |
20 | 26 | from server.models.db_models import Project, ProjectFile |
| 27 | +from server.nextcloud_client import ( |
| 28 | + MANIFEST_FILENAME, |
| 29 | + NextcloudError, |
| 30 | + get_nc_client, |
| 31 | +) |
| 32 | +from server.tenant_config import TenantConfig, get_tenants |
| 33 | +from server.volume_reader import VolumeReader |
21 | 34 |
|
22 | 35 | logger = logging.getLogger(__name__) |
23 | 36 |
|
24 | 37 | router = APIRouter(prefix="/api/v2", tags=["projects"]) |
25 | 38 |
|
| 39 | +# Default tenant slug when multi-tenant resolution is not yet active |
| 40 | +DEFAULT_TENANT_SLUG = "3bm" |
| 41 | + |
26 | 42 | # Base directory for project file storage |
27 | 43 | PROJECT_FILES_DIR = Path( |
28 | 44 | os.environ.get("PROJECT_FILES_DIR", "/data/projects") |
@@ -265,3 +281,182 @@ async def delete_file(project_id: str, file_id: str): |
265 | 281 | "Deleted file %s from project %s", file_id, project_id |
266 | 282 | ) |
267 | 283 | return {"success": True, "message": f"File {file_id} deleted"} |
| 284 | + |
| 285 | + |
| 286 | +# ── .wefc envelope (bridges persistent project to tenant cloud) ── |
| 287 | + |
| 288 | + |
| 289 | +def _resolve_tenant_from_request(request: Request) -> TenantConfig: |
| 290 | + """Resolve tenant from Authentik forward-auth headers. |
| 291 | +
|
| 292 | + Looks for X-Authentik-Meta-Tenant first (custom OpenAEC profile |
| 293 | + scope), falls back to a query parameter ``tenant`` and finally to |
| 294 | + the default tenant slug. |
| 295 | +
|
| 296 | + Raises: |
| 297 | + HTTPException 401 when no Authentik user header is present. |
| 298 | + HTTPException 503 when no tenants are configured at all. |
| 299 | + HTTPException 403 when the requested tenant slug is unknown. |
| 300 | + """ |
| 301 | + # Auth gate — Authentik forward-auth always sets a username header |
| 302 | + # for authenticated requests. Reject when missing. |
| 303 | + if not request.headers.get("X-authentik-username"): |
| 304 | + raise HTTPException(401, "Not authenticated") |
| 305 | + |
| 306 | + registry = get_tenants() |
| 307 | + if not registry.is_configured: |
| 308 | + raise HTTPException(503, "Cloud storage not configured (no tenants)") |
| 309 | + |
| 310 | + # Header is set by Authentik when the openaec_profile scope is mapped. |
| 311 | + slug = ( |
| 312 | + request.headers.get("X-Authentik-Meta-Tenant") |
| 313 | + or request.query_params.get("tenant") |
| 314 | + or DEFAULT_TENANT_SLUG |
| 315 | + ) |
| 316 | + |
| 317 | + config = registry.get(slug) |
| 318 | + if not config: |
| 319 | + raise HTTPException(403, f"Unknown tenant: {slug}") |
| 320 | + return config |
| 321 | + |
| 322 | + |
| 323 | +def _nc_error_to_http(exc: Exception) -> HTTPException: |
| 324 | + """Map a NextcloudError to an HTTP response.""" |
| 325 | + if isinstance(exc, NextcloudError) and exc.status_code: |
| 326 | + return HTTPException(status_code=exc.status_code, detail=str(exc)) |
| 327 | + return HTTPException(status_code=502, detail=f"Cloud backend error: {exc}") |
| 328 | + |
| 329 | + |
| 330 | +def _wefc_filename_for_project(project: Project) -> str: |
| 331 | + """Return the .wefc filename used for a persistent project. |
| 332 | +
|
| 333 | + Uses a stable, project-id derived name so multiple projects with |
| 334 | + the same display name do not collide in the same Nextcloud folder. |
| 335 | + The plain project.wefc remains reserved for the legacy/default |
| 336 | + container. |
| 337 | + """ |
| 338 | + return f"project-{project.id}.wefc" |
| 339 | + |
| 340 | + |
| 341 | +def _project_folder_name(project: Project) -> str: |
| 342 | + """Return the Nextcloud folder name for a persistent project. |
| 343 | +
|
| 344 | + Convention: all v2 project envelopes live under a single shared |
| 345 | + ``Projects/`` namespace, in a per-project folder derived from the |
| 346 | + project name. Falls back to the project id if no name is set. |
| 347 | + """ |
| 348 | + name = (project.name or "").strip() |
| 349 | + if not name: |
| 350 | + return f"project-{project.id}" |
| 351 | + return name |
| 352 | + |
| 353 | + |
| 354 | +@router.get("/projects/{project_id}/wefc") |
| 355 | +async def get_project_wefc(project_id: str, request: Request): |
| 356 | + """Return the .wefc envelope for a persistent project. |
| 357 | +
|
| 358 | + Resolves the tenant from forward-auth headers, locates the |
| 359 | + project's Nextcloud folder, and reads the manifest via the volume |
| 360 | + mount when available, falling back to WebDAV otherwise. |
| 361 | + """ |
| 362 | + config = _resolve_tenant_from_request(request) |
| 363 | + |
| 364 | + async with get_session() as session: |
| 365 | + project = await session.get(Project, project_id) |
| 366 | + if not project: |
| 367 | + raise HTTPException(404, f"Project not found: {project_id}") |
| 368 | + folder = _project_folder_name(project) |
| 369 | + manifest_name = _wefc_filename_for_project(project) |
| 370 | + |
| 371 | + # Fast path: read from the tenant's volume mount |
| 372 | + reader = VolumeReader(config) |
| 373 | + if reader.available: |
| 374 | + manifest = reader.read_manifest(folder, manifest_name) |
| 375 | + if manifest is None: |
| 376 | + # Backward-compat: also try the default project.wefc for projects |
| 377 | + # that were saved before the project-id naming convention. |
| 378 | + manifest = reader.read_manifest(folder, MANIFEST_FILENAME) |
| 379 | + if manifest is not None: |
| 380 | + return JSONResponse(content=manifest) |
| 381 | + |
| 382 | + # Fallback: WebDAV |
| 383 | + client = get_nc_client(config) |
| 384 | + try: |
| 385 | + manifest = await client.read_manifest(folder, manifest_name) |
| 386 | + if manifest is None: |
| 387 | + manifest = await client.read_manifest(folder, MANIFEST_FILENAME) |
| 388 | + except Exception as exc: |
| 389 | + raise _nc_error_to_http(exc) from exc |
| 390 | + |
| 391 | + if manifest is None: |
| 392 | + raise HTTPException( |
| 393 | + 404, |
| 394 | + f"No .wefc envelope for project {project_id} (folder: {folder})", |
| 395 | + ) |
| 396 | + return JSONResponse(content=manifest) |
| 397 | + |
| 398 | + |
| 399 | +@router.put("/projects/{project_id}/wefc") |
| 400 | +async def put_project_wefc( |
| 401 | + project_id: str, |
| 402 | + request: Request, |
| 403 | + manifest: dict[str, Any], |
| 404 | +): |
| 405 | + """Write the .wefc envelope for a persistent project. |
| 406 | +
|
| 407 | + Validates that the body is a dict, ensures the manifest header is |
| 408 | + present and current, and writes via WebDAV (always — volume mount |
| 409 | + is read-only for the bim-validator process). |
| 410 | + """ |
| 411 | + config = _resolve_tenant_from_request(request) |
| 412 | + |
| 413 | + if not isinstance(manifest, dict): |
| 414 | + raise HTTPException(400, "Body must be a JSON object") |
| 415 | + |
| 416 | + async with get_session() as session: |
| 417 | + project = await session.get(Project, project_id) |
| 418 | + if not project: |
| 419 | + raise HTTPException(404, f"Project not found: {project_id}") |
| 420 | + folder = _project_folder_name(project) |
| 421 | + manifest_name = _wefc_filename_for_project(project) |
| 422 | + |
| 423 | + # Ensure the manifest header is up to date |
| 424 | + header = manifest.get("header") if isinstance(manifest.get("header"), dict) else {} |
| 425 | + header.setdefault("schema", "WeFC") |
| 426 | + header.setdefault("schema_version", "1.1.0") |
| 427 | + header.setdefault("fileId", str(uuid.uuid4())) |
| 428 | + header["timestamp"] = datetime.now(timezone.utc).isoformat() |
| 429 | + header["application"] = "bim-validator" |
| 430 | + header["projectId"] = project_id |
| 431 | + header["projectName"] = project.name |
| 432 | + manifest["header"] = header |
| 433 | + |
| 434 | + # Touch the project's updated_at so the listing reflects the save. |
| 435 | + async with get_session() as session: |
| 436 | + db_project = await session.get(Project, project_id) |
| 437 | + if db_project is not None: |
| 438 | + db_project.updated_at = datetime.now(timezone.utc) |
| 439 | + |
| 440 | + client = get_nc_client(config) |
| 441 | + try: |
| 442 | + await client.write_manifest(folder, manifest, manifest_name) |
| 443 | + except Exception as exc: |
| 444 | + raise _nc_error_to_http(exc) from exc |
| 445 | + |
| 446 | + logger.info( |
| 447 | + "Wrote .wefc envelope for project %s (folder=%s, file=%s, tenant=%s)", |
| 448 | + project_id, |
| 449 | + folder, |
| 450 | + manifest_name, |
| 451 | + config.slug, |
| 452 | + ) |
| 453 | + |
| 454 | + return JSONResponse( |
| 455 | + content={ |
| 456 | + "success": True, |
| 457 | + "projectId": project_id, |
| 458 | + "folder": folder, |
| 459 | + "manifest": manifest_name, |
| 460 | + "tenant": config.slug, |
| 461 | + } |
| 462 | + ) |
0 commit comments