421 lines
15 KiB
Python
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"]
|