Files
NODEDC_TASKMANAGER/plane-src/apps/api/plane/utils/nodedc_bim_gateway.py
T

421 lines
15 KiB
Python

# 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"]