# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. import base64 import hashlib import hmac import json import os import time from urllib.parse import urlencode, urljoin, urlsplit import requests BIM_EMBED_AUDIENCE = "nodedc-bim-tasker-embed" BIM_EMBED_ISSUER = "nodedc-tasker" BIM_SUPPORTED_MODEL_TYPES = { "bim": "bim", "glb": "gltf", "gltf": "gltf", "iges": "iges", "igs": "iges", "las": "las", "laz": "las", "obj": "obj", "step": "step", "stl": "stl", "stp": "step", "xkt": "xkt", } BIM_DIRECT_MODEL_TYPES = { "bim": "bim", "glb": "gltf", "gltf": "gltf", "las": "las", "laz": "las", "obj": "obj", "stl": "stl", "xkt": "xkt", } class BimGatewayError(Exception): def __init__(self, code, message, status_code=502, upstream_status=None): super().__init__(message) self.code = code self.message = message self.status_code = status_code self.upstream_status = upstream_status def get_bim_gateway_config(): public_base_url = ( os.environ.get("PLANE_NODEDC_BIM_PUBLIC_URL", "").strip() or os.environ.get("NODEDC_BIM_PUBLIC_URL", "").strip() or "https://bim.nodedc.tech" ).rstrip("/") internal_base_url = ( os.environ.get("PLANE_NODEDC_BIM_INTERNAL_URL", "").strip() or os.environ.get("PLANE_NODEDC_BIM_URL", "").strip() or public_base_url ).rstrip("/") token = ( os.environ.get("PLANE_NODEDC_BIM_ACCESS_TOKEN", "").strip() or os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip() or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip() ) embed_secret = ( os.environ.get("PLANE_NODEDC_BIM_EMBED_SECRET", "").strip() or os.environ.get("NODEDC_BIM_EMBED_SECRET", "").strip() or token ) connect_timeout = float(os.environ.get("PLANE_NODEDC_BIM_CONNECT_TIMEOUT_SECONDS", "5") or "5") read_timeout = float(os.environ.get("PLANE_NODEDC_BIM_READ_TIMEOUT_SECONDS", "180") or "180") embed_ttl = max(30, int(os.environ.get("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180") or "180")) return { "public_base_url": public_base_url, "internal_base_url": internal_base_url, "token": token, "embed_secret": embed_secret, "timeout": (connect_timeout, read_timeout), "embed_ttl": embed_ttl, } def get_bim_model_type(filename): extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") return BIM_SUPPORTED_MODEL_TYPES.get(extension) def get_bim_direct_model_type(filename): extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") return BIM_DIRECT_MODEL_TYPES.get(extension) def get_bim_model_mime_type(filename): extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".") if extension == "glb": return "model/gltf-binary" if extension == "gltf": return "model/gltf+json" return "application/octet-stream" def make_bim_registry_owner_id(attachment_id): return "tasker-attachment:{}".format(attachment_id) def get_bim_registry_identity(attachment, beam_viewer=None): beam_viewer = beam_viewer if isinstance(beam_viewer, dict) else {} registry_owner_id = beam_viewer.get("registryOwnerId") if isinstance(registry_owner_id, str) and registry_owner_id.strip(): return { "id": registry_owner_id.strip(), "email": "", } created_by = getattr(attachment, "created_by", None) created_by_id = getattr(attachment, "created_by_id", None) return { "id": str(created_by_id or getattr(attachment, "id", "")), "email": getattr(created_by, "email", "") or "", } def _gateway_headers(identity, content_type=None, content_length=None): config = get_bim_gateway_config() if not config["token"]: raise BimGatewayError( "bim_gateway_not_configured", "NODE.DC BIM gateway token is not configured.", status_code=503, ) identity = identity or {} headers = { "Authorization": "Bearer {}".format(config["token"]), "Accept": "application/json", "X-NODEDC-Service": "tasker", "X-NODEDC-User-Id": str(identity.get("id") or "tasker"), "X-NODEDC-User-Email": str(identity.get("email") or ""), } if content_type: headers["Content-Type"] = content_type if content_length is not None: headers["Content-Length"] = str(max(0, int(content_length))) return headers def bim_gateway_request(method, path, identity=None, params=None, json_payload=None, data=None, content_length=None): config = get_bim_gateway_config() try: response = requests.request( method, "{}{}".format(config["internal_base_url"], path), params=params, json=json_payload, data=data, headers=_gateway_headers( identity, content_type="application/octet-stream" if data is not None else ("application/json" if json_payload is not None else None), content_length=content_length, ), timeout=config["timeout"], ) except requests.RequestException as exc: raise BimGatewayError( "bim_gateway_unavailable", "NODE.DC BIM gateway is unavailable.", status_code=502, ) from exc try: payload = response.json() except ValueError: payload = None if response.status_code >= 400: upstream_message = payload.get("message") if isinstance(payload, dict) else None upstream_error = payload.get("error") if isinstance(payload, dict) else None status_code = response.status_code if response.status_code in {400, 404, 409, 413, 422} else 502 raise BimGatewayError( upstream_error or "bim_gateway_rejected", upstream_message or "NODE.DC BIM gateway rejected the request.", status_code=status_code, upstream_status=response.status_code, ) if not isinstance(payload, dict): raise BimGatewayError( "bim_gateway_invalid_response", "NODE.DC BIM gateway returned an invalid response.", status_code=502, upstream_status=response.status_code, ) return payload def to_bim_public_url(value): if not isinstance(value, str) or not value.strip(): return None config = get_bim_gateway_config() raw_value = value.strip() parsed = urlsplit(raw_value) if parsed.scheme or parsed.netloc: raw_value = parsed.path if parsed.query: raw_value = "{}?{}".format(raw_value, parsed.query) return urljoin("{}/".format(config["public_base_url"]), raw_value.lstrip("/")) def to_bim_relative_asset_url(value): if not isinstance(value, str) or not value.strip(): return None config = get_bim_gateway_config() raw_value = value.strip() parsed = urlsplit(raw_value) if parsed.scheme or parsed.netloc: public_origin = urlsplit(config["public_base_url"]) if (parsed.scheme, parsed.netloc) != (public_origin.scheme, public_origin.netloc): return None relative = parsed.path if parsed.query: relative = "{}?{}".format(relative, parsed.query) else: relative = "/{}".format(raw_value.lstrip("/")) path_only = urlsplit(relative).path if not (path_only.startswith("/uploads/") or path_only.startswith("/data/")): return None return relative def _model_version_record(beam_viewer, size=0): conversion = beam_viewer.get("conversion") if isinstance(beam_viewer.get("conversion"), dict) else None return { "assetId": beam_viewer.get("assetId"), "conversion": conversion, "downloadUrl": beam_viewer.get("downloadUrl"), "originalFilename": beam_viewer.get("originalFilename"), "previewAvailable": bool(beam_viewer.get("previewAvailable")), "projectId": beam_viewer.get("projectId"), "sha256": beam_viewer.get("sha256"), "size": int(size or (conversion or {}).get("size") or 0), "sourceSrc": (conversion or {}).get("sourceSrc") or beam_viewer.get("src"), "src": beam_viewer.get("src"), "status": (conversion or {}).get("status") or "ready", "type": beam_viewer.get("type"), "uploadedBy": beam_viewer.get("uploadedBy"), "uploadedAt": beam_viewer.get("uploadedAt"), "version": int(beam_viewer.get("version") or 1), "versionId": beam_viewer.get("versionId"), "viewerUrl": None, } def build_bim_attachment(upload_payload, filename, size, uploaded_by, registry_owner_id): source_src = upload_payload.get("src") if not isinstance(source_src, str) or not source_src: raise BimGatewayError( "bim_gateway_invalid_response", "NODE.DC BIM gateway did not return an uploaded model path.", status_code=502, ) model_type = get_bim_model_type(filename) direct_type = get_bim_direct_model_type(filename) conversion = upload_payload.get("conversion") if isinstance(conversion, dict): conversion = { **conversion, "componentTreeRequired": True, "message": conversion.get("message") or "Оригинальная CAD-модель загружена. Просмотр появится после подготовки модели и дерева компонентов.", "sourceFormat": conversion.get("sourceFormat") or model_type, "status": conversion.get("status") or "conversion_required", "targetFormat": conversion.get("targetFormat") or "xkt", } else: conversion = None source_url = to_bim_public_url(source_src) beam_viewer = { "assetId": upload_payload.get("assetId"), "backend": "beam-viewer-ops", "downloadUrl": source_url, "originalFilename": upload_payload.get("originalFilename") or filename, "previewAvailable": bool(direct_type), "projectId": upload_payload.get("projectId"), "registryOwnerId": registry_owner_id, "sha256": upload_payload.get("sha256"), "src": source_url, "type": model_type, "uploadedBy": str(uploaded_by) if uploaded_by else None, "uploadedAt": upload_payload.get("uploadedAt"), "version": int(upload_payload.get("version") or 1), "versionId": upload_payload.get("versionId"), "viewerUrl": None, } if conversion: beam_viewer["conversion"] = conversion beam_viewer["versions"] = [_model_version_record(beam_viewer, size=size)] return beam_viewer def merge_bim_versions(previous_beam_viewer, next_beam_viewer): versions = previous_beam_viewer.get("versions") versions = list(versions) if isinstance(versions, list) else [] if not versions: versions.append(_model_version_record(previous_beam_viewer)) next_versions = next_beam_viewer.get("versions") next_record = ( next_versions[0] if isinstance(next_versions, list) and next_versions else _model_version_record(next_beam_viewer) ) next_version_id = next_record.get("versionId") next_version = next_record.get("version") versions = [ version for version in versions if not ( (next_version_id and version.get("versionId") == next_version_id) or (not next_version_id and version.get("version") == next_version) ) ] versions.append(next_record) return sorted(versions, key=lambda version: int(version.get("version") or 0)) def find_bim_version(beam_viewer, version_id): versions = beam_viewer.get("versions") if not isinstance(versions, list): versions = [] if version_id: for version in versions: if version.get("versionId") == version_id or str(version.get("version")) == str(version_id): return version return None return _model_version_record(beam_viewer) def resolve_bim_viewer_model(beam_viewer, version_id=None, live_status=None): record = find_bim_version(beam_viewer, version_id) if not isinstance(record, dict): raise BimGatewayError("bim_version_not_found", "BIM model version was not found.", status_code=404) conversion = record.get("conversion") if isinstance(record.get("conversion"), dict) else {} if live_status and isinstance(live_status, dict): conversion = {**conversion, **live_status} if conversion.get("status") == "ready": source = conversion.get("artifactUrl") or conversion.get("artifactSrc") model_type = conversion.get("artifactType") or conversion.get("targetFormat") or "gltf" settings_source = conversion.get("sourceSrc") or record.get("sourceSrc") or record.get("src") elif record.get("previewAvailable"): source = record.get("src") model_type = record.get("type") settings_source = record.get("sourceSrc") or record.get("src") else: raise BimGatewayError( "bim_model_not_ready", "BIM model preview is still being prepared.", status_code=409, ) source = to_bim_relative_asset_url(source) settings_source = to_bim_relative_asset_url(settings_source) if not source or not settings_source: raise BimGatewayError( "bim_model_source_invalid", "BIM model source is outside the managed BIM storage.", status_code=422, ) return { "url": source, "settingsSrc": settings_source, "type": model_type, "name": record.get("originalFilename") or beam_viewer.get("originalFilename") or "model", } def _base64url(value): return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") def build_bim_embed_url(model, user, workspace_slug, project_id, issue_id, attachment_id): config = get_bim_gateway_config() if not config["embed_secret"]: raise BimGatewayError( "bim_embed_not_configured", "NODE.DC BIM embed signing secret is not configured.", status_code=503, ) now = int(time.time()) payload = { "v": 1, "aud": BIM_EMBED_AUDIENCE, "iss": BIM_EMBED_ISSUER, "iat": now, "exp": now + config["embed_ttl"], "sub": str(getattr(user, "id", "")), "workspace": str(workspace_slug), "project": str(project_id), "issue": str(issue_id), "attachment": str(attachment_id), "model": model, } encoded_payload = _base64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")) signature = hmac.new( config["embed_secret"].encode("utf-8"), encoded_payload.encode("ascii"), hashlib.sha256, ).digest() token = "{}.{}".format(encoded_payload, _base64url(signature)) query = urlencode( { "token": token, "url": model["url"], "settingsSrc": model["settingsSrc"], "type": model["type"], "name": model["name"], } ) return "{}/embed/tasker?{}".format(config["public_base_url"], query), payload["exp"]