Skip to content

Commit f73da0e

Browse files
jochem25claude
andcommitted
feat(cloud): .wefc envelope endpoints + Backstage cloud-save wiring
Voegt GET/PUT /api/v2/projects/{id}/wefc endpoints toe in de Python FastAPI backend. Bridge naar bestaande NextcloudClient via project.name als folder en project-{id}.wefc als manifestnaam. Volume-mount fast-path + WebDAV fallback. Auth via Authentik forward_auth headers (X-Authentik- Username + X-Authentik-Meta-Tenant) met 401/403/404/503/502 fout-mapping. Backstage.tsx vervangt placeholder cloud open/save calls met echte project-id-based endpoints — niet meer hardcoded "current-project": - handleSave leest bestaande envelope en merget header voor in-place update via saveProjectWefc(project.id, ...) - handleSaveAsServer maakt nieuw v2 project via createProject() en schrijft envelope met canonical id - handleSaveAsLocal exporteert echte WeFC JSON envelope - handleFileSelected valideert WeFC header bij lokale .wefc open - ProjectIoSlice setSaveInfo + markClean voor dirty-tracking Build: viewer npm run build exit 0, tsc strict exit 0. Tests: server pytest cloud + NC client (26 passed, geen regressies). Plan: docs/2026-04-16-week-plan-authentik-saveux.md week 2 cloud-save Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f29a5c2 commit f73da0e

3 files changed

Lines changed: 390 additions & 62 deletions

File tree

server/routers/projects.py

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,41 @@
44
Endpoints for CRUD on projects, and upload/download/delete of
55
IFC, BCF, and IDS files within projects. Files are stored on disk
66
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.
710
"""
811

12+
import json
913
import logging
1014
import os
1115
import shutil
1216
import uuid
17+
from datetime import datetime, timezone
1318
from pathlib import Path
19+
from typing import Any
1420

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
1723
from sqlalchemy import select
1824

1925
from server.database import get_session
2026
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
2134

2235
logger = logging.getLogger(__name__)
2336

2437
router = APIRouter(prefix="/api/v2", tags=["projects"])
2538

39+
# Default tenant slug when multi-tenant resolution is not yet active
40+
DEFAULT_TENANT_SLUG = "3bm"
41+
2642
# Base directory for project file storage
2743
PROJECT_FILES_DIR = Path(
2844
os.environ.get("PROJECT_FILES_DIR", "/data/projects")
@@ -265,3 +281,182 @@ async def delete_file(project_id: str, file_id: str):
265281
"Deleted file %s from project %s", file_id, project_id
266282
)
267283
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+
)

viewer/src/api/projectApi.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,36 @@ export async function getElementProperties(
283283
);
284284
return handleResponse<ElementProperties>(response);
285285
}
286+
287+
// ── .wefc envelope (cloud-bridge) ─────────────────────────────
288+
289+
/**
290+
* Read the .wefc envelope for a persistent project from the tenant
291+
* Nextcloud folder. Returns ``null`` when no envelope exists yet.
292+
*/
293+
export async function readProjectWefc(
294+
projectId: string
295+
): Promise<Record<string, unknown> | null> {
296+
const response = await fetch(`${API_V2}/projects/${projectId}/wefc`);
297+
if (response.status === 404) {
298+
return null;
299+
}
300+
return handleResponse<Record<string, unknown>>(response);
301+
}
302+
303+
/**
304+
* Write the .wefc envelope for a persistent project. The backend
305+
* persists the manifest in the tenant Nextcloud folder via WebDAV
306+
* and refreshes the project's ``updated_at`` timestamp.
307+
*/
308+
export async function saveProjectWefc(
309+
projectId: string,
310+
manifest: Record<string, unknown>
311+
): Promise<void> {
312+
const response = await fetch(`${API_V2}/projects/${projectId}/wefc`, {
313+
method: "PUT",
314+
headers: { "Content-Type": "application/json" },
315+
body: JSON.stringify(manifest),
316+
});
317+
await handleResponse(response);
318+
}

0 commit comments

Comments
 (0)