ARCH - TASKER BIM: серверный CAD-шлюз и независимый просмотр из Ops

This commit is contained in:
DCCONSTRUCTIONS
2026-07-30 07:35:23 +03:00
parent 24481ea432
commit ad04910209
10 changed files with 1252 additions and 499 deletions
@@ -26,6 +26,11 @@ from plane.app.views import (
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBimAttachmentStatusEndpoint,
IssueBimAttachmentUploadEndpoint,
IssueBimAttachmentVersionDetailEndpoint,
IssueBimAttachmentVersionsEndpoint,
IssueBimAttachmentViewerEndpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
WorkItemDescriptionVersionEndpoint,
@@ -144,6 +149,31 @@ urlpatterns = [
IssueAttachmentV2Endpoint.as_view(),
name="project-issue-attachments",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/bim-upload/",
IssueBimAttachmentUploadEndpoint.as_view(),
name="project-issue-bim-attachment-upload",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-status/",
IssueBimAttachmentStatusEndpoint.as_view(),
name="project-issue-bim-attachment-status",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/",
IssueBimAttachmentVersionsEndpoint.as_view(),
name="project-issue-bim-attachment-versions",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/<str:version_id>/",
IssueBimAttachmentVersionDetailEndpoint.as_view(),
name="project-issue-bim-attachment-version-detail",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-viewer/",
IssueBimAttachmentViewerEndpoint.as_view(),
name="project-issue-bim-attachment-viewer",
),
## End Issues
## Issue Activity
path(
@@ -145,6 +145,14 @@ from .issue.attachment import (
IssueAttachmentV2Endpoint,
)
from .issue.bim_attachment import (
IssueBimAttachmentStatusEndpoint,
IssueBimAttachmentUploadEndpoint,
IssueBimAttachmentVersionDetailEndpoint,
IssueBimAttachmentVersionsEndpoint,
IssueBimAttachmentViewerEndpoint,
)
from .issue.comment import IssueCommentViewSet, CommentReactionViewSet
from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint
@@ -27,6 +27,7 @@ from plane.utils.host import base_host
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
from plane.utils.nodedc_bim_gateway import BimGatewayError, bim_gateway_request, get_bim_registry_identity
class IssueAttachmentEndpoint(BaseAPIView):
@@ -109,12 +110,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
name = request.data.get("name")
type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
beam_viewer = request.data.get("beamViewer")
is_beam_viewer_reference = (
isinstance(beam_viewer, dict)
and beam_viewer.get("src")
and beam_viewer.get("downloadUrl")
)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@@ -126,45 +121,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
if is_beam_viewer_reference:
asset = FileAsset.objects.create(
attributes={
"name": name,
"type": type,
"size": size,
"beamViewer": beam_viewer,
},
asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}",
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@@ -205,7 +161,32 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment = FileAsset.objects.select_related("created_by").get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
)
attributes = issue_attachment.attributes if isinstance(issue_attachment.attributes, dict) else {}
beam_viewer = attributes.get("beamViewer")
if issue_attachment.external_source == "beam-viewer" and isinstance(beam_viewer, dict):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/asset",
identity=get_bim_registry_identity(issue_attachment, beam_viewer),
json_payload=beam_viewer,
)
except BimGatewayError as error:
return Response(
{
"ok": False,
"error": error.code,
"message": error.message,
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
},
status=error.status_code,
)
if not issue_attachment.is_uploaded:
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
issue_attachment.is_deleted = True
@@ -256,28 +237,22 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment = FileAsset.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
beam_viewer = request.data.get("beamViewer")
if isinstance(beam_viewer, dict):
attributes = issue_attachment.attributes or {}
existing_beam_viewer = attributes.get("beamViewer")
if not isinstance(existing_beam_viewer, dict):
return Response(
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
attributes["beamViewer"] = {
**existing_beam_viewer,
**beam_viewer,
}
for attribute_key in ("name", "size", "type", "version"):
if attribute_key in request.data:
attributes[attribute_key] = request.data.get(attribute_key)
issue_attachment.attributes = attributes
issue_attachment.is_uploaded = True
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK)
return Response(
{
"error": "BIM attachments are managed by the Ops BIM gateway.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
@@ -0,0 +1,465 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import logging
import re
import uuid
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import IssueAttachmentSerializer
from plane.app.views import BaseAPIView
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import FileAsset, Project, Workspace
from plane.utils.host import base_host
from plane.utils.nodedc_bim_gateway import (
BimGatewayError,
bim_gateway_request,
build_bim_attachment,
build_bim_embed_url,
find_bim_version,
get_bim_model_mime_type,
get_bim_model_type,
get_bim_registry_identity,
make_bim_registry_owner_id,
merge_bim_versions,
resolve_bim_viewer_model,
to_bim_public_url,
to_bim_relative_asset_url,
)
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
logger = logging.getLogger(__name__)
def _error_response(error):
return Response(
{
"ok": False,
"error": error.code,
"message": error.message,
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
},
status=error.status_code,
)
def _get_attachment(slug, project_id, issue_id, attachment_id):
return (
FileAsset.objects.select_related("created_by")
.filter(
id=attachment_id,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
.first()
)
def _get_beam_viewer(attachment):
attributes = attachment.attributes if isinstance(attachment.attributes, dict) else {}
beam_viewer = attributes.get("beamViewer")
return beam_viewer if isinstance(beam_viewer, dict) else None
def _upload_group_id(slug, project_id, issue_id):
value = "{}_{}_{}".format(slug, project_id, issue_id)
return re.sub(r"[^a-zA-Z0-9_-]", "_", value) or "tasker"
def _validate_upload(workspace, uploaded_file):
if uploaded_file is None:
return Response(
{"ok": False, "error": "bim_file_required", "message": "A BIM model file is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if not get_bim_model_type(uploaded_file.name):
return Response(
{
"ok": False,
"error": "bim_format_not_supported",
"message": "This BIM model format is not supported.",
},
status=status.HTTP_400_BAD_REQUEST,
)
size_limit = resolve_workspace_upload_size_limit(workspace, uploaded_file.size)
if uploaded_file.size > size_limit:
return Response(
{
"ok": False,
"error": "bim_file_too_large",
"message": "The BIM model exceeds the workspace upload limit.",
"limit": size_limit,
},
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
return None
def _activity_created(request, serializer, issue_id, project_id):
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
def _delete_uploaded_asset(beam_viewer, identity):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/asset",
identity=identity,
json_payload=beam_viewer,
)
except BimGatewayError:
logger.exception("Failed to clean up BIM asset after an OPS attachment failure")
def _delete_uploaded_version(version, identity):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/version",
identity=identity,
json_payload=version,
)
except BimGatewayError:
logger.exception("Failed to clean up BIM version after an OPS attachment failure")
def _public_status_payload(payload):
result = dict(payload)
result["artifactUrl"] = to_bim_public_url(payload.get("artifactSrc"))
result["metadataUrl"] = to_bim_public_url(payload.get("metadataSrc"))
return result
def _public_version(version):
result = dict(version)
for key in ("src", "sourceSrc", "downloadUrl"):
if result.get(key):
result[key] = to_bim_public_url(result[key])
result["viewerUrl"] = None
return result
class IssueBimAttachmentUploadEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id):
workspace = Workspace.objects.get(slug=slug)
Project.objects.get(id=project_id, workspace=workspace)
uploaded_file = request.FILES.get("file")
validation_error = _validate_upload(workspace, uploaded_file)
if validation_error is not None:
return validation_error
attachment_id = uuid.uuid4()
registry_owner_id = make_bim_registry_owner_id(attachment_id)
identity = {"id": registry_owner_id, "email": request.user.email or ""}
uploaded_file.seek(0)
try:
upload_payload = bim_gateway_request(
"POST",
"/api/uploads",
identity=identity,
params={
"filename": uploaded_file.name,
"projectId": _upload_group_id(slug, project_id, issue_id),
"assetId": str(attachment_id),
"version": "1",
},
data=uploaded_file.file,
content_length=uploaded_file.size,
)
beam_viewer = build_bim_attachment(
upload_payload,
filename=uploaded_file.name,
size=uploaded_file.size,
uploaded_by=request.user.id,
registry_owner_id=registry_owner_id,
)
except BimGatewayError as error:
return _error_response(error)
try:
asset = FileAsset.objects.create(
id=attachment_id,
attributes={
"name": uploaded_file.name,
"type": get_bim_model_mime_type(uploaded_file.name),
"size": uploaded_file.size,
"version": beam_viewer.get("version") or 1,
"beamViewer": beam_viewer,
},
asset="{}/beam-viewer/{}-{}".format(workspace.id, attachment_id.hex, uploaded_file.name),
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
_activity_created(request, serializer, issue_id, project_id)
except Exception:
_delete_uploaded_asset(beam_viewer, identity)
raise
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_201_CREATED,
)
class IssueBimAttachmentStatusEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
source = to_bim_relative_asset_url(beam_viewer.get("src"))
if not source:
return Response({"error": "BIM attachment source is invalid."}, status=status.HTTP_422_UNPROCESSABLE_ENTITY)
try:
payload = bim_gateway_request(
"GET",
"/api/conversions/status",
identity=get_bim_registry_identity(attachment, beam_viewer),
params={"src": source},
)
except BimGatewayError as error:
return _error_response(error)
return Response(_public_status_payload(payload), status=status.HTTP_200_OK)
class IssueBimAttachmentVersionsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
params = {
"projectId": beam_viewer.get("projectId"),
"assetId": beam_viewer.get("assetId"),
}
if not all(params.values()):
params = {"src": to_bim_relative_asset_url(beam_viewer.get("src"))}
try:
payload = bim_gateway_request(
"GET",
"/api/uploads/versions",
identity=get_bim_registry_identity(attachment, beam_viewer),
params=params,
)
except BimGatewayError as error:
return _error_response(error)
result = dict(payload)
result["versions"] = [_public_version(version) for version in payload.get("versions") or []]
return Response(result, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
workspace = Workspace.objects.get(slug=slug)
uploaded_file = request.FILES.get("file")
validation_error = _validate_upload(workspace, uploaded_file)
if validation_error is not None:
return validation_error
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
current_versions = [int(beam_viewer.get("version") or 1)]
current_versions.extend(int(version.get("version") or 0) for version in versions)
next_version = max(current_versions) + 1
identity = get_bim_registry_identity(attachment, beam_viewer)
uploaded_file.seek(0)
try:
upload_payload = bim_gateway_request(
"POST",
"/api/uploads",
identity=identity,
params={
"filename": uploaded_file.name,
"projectId": beam_viewer.get("projectId") or _upload_group_id(slug, project_id, issue_id),
"assetId": beam_viewer.get("assetId") or str(attachment.id),
"version": str(next_version),
},
data=uploaded_file.file,
content_length=uploaded_file.size,
)
next_beam_viewer = build_bim_attachment(
upload_payload,
filename=uploaded_file.name,
size=uploaded_file.size,
uploaded_by=request.user.id,
registry_owner_id=identity["id"],
)
next_beam_viewer["versions"] = merge_bim_versions(beam_viewer, next_beam_viewer)
except BimGatewayError as error:
return _error_response(error)
next_record = find_bim_version(next_beam_viewer, next_beam_viewer.get("versionId"))
try:
attributes = dict(attachment.attributes or {})
attributes.update(
{
"name": uploaded_file.name,
"type": get_bim_model_mime_type(uploaded_file.name),
"size": uploaded_file.size,
"version": next_beam_viewer.get("version") or next_version,
"beamViewer": next_beam_viewer,
}
)
attachment.attributes = attributes
attachment.save(update_fields=["attributes", "updated_at"])
except Exception:
if next_record:
_delete_uploaded_version(next_record, identity)
raise
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
class IssueBimAttachmentVersionDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, issue_id, pk, version_id):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
version = find_bim_version(beam_viewer, version_id)
if version is None:
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
current_version_id = beam_viewer.get("versionId")
if (current_version_id and current_version_id == version.get("versionId")) or (
not current_version_id and beam_viewer.get("version") == version.get("version")
):
return Response(
{"error": "The current BIM version cannot be deleted."},
status=status.HTTP_409_CONFLICT,
)
try:
bim_gateway_request(
"DELETE",
"/api/uploads/version",
identity=get_bim_registry_identity(attachment, beam_viewer),
json_payload=version,
)
except BimGatewayError as error:
return _error_response(error)
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
beam_viewer = {
**beam_viewer,
"versions": [
candidate
for candidate in versions
if not (
(version.get("versionId") and candidate.get("versionId") == version.get("versionId"))
or (not version.get("versionId") and candidate.get("version") == version.get("version"))
)
],
}
attributes = dict(attachment.attributes or {})
attributes["beamViewer"] = beam_viewer
attachment.attributes = attributes
attachment.save(update_fields=["attributes", "updated_at"])
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
class IssueBimAttachmentViewerEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
version_id = request.data.get("versionId")
version = find_bim_version(beam_viewer, version_id)
if version is None:
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
live_status = None
conversion = version.get("conversion") if isinstance(version.get("conversion"), dict) else {}
if conversion and conversion.get("status") != "ready":
source = to_bim_relative_asset_url(version.get("sourceSrc") or version.get("src"))
if source:
try:
live_status = _public_status_payload(
bim_gateway_request(
"GET",
"/api/conversions/status",
identity=get_bim_registry_identity(attachment, beam_viewer),
params={"src": source},
)
)
except BimGatewayError as error:
return _error_response(error)
try:
model = resolve_bim_viewer_model(
beam_viewer,
version_id=version_id,
live_status=live_status,
)
viewer_url, expires_at = build_bim_embed_url(
model,
request.user,
slug,
project_id,
issue_id,
attachment.id,
)
except BimGatewayError as error:
return _error_response(error)
return Response(
{
"ok": True,
"viewerUrl": viewer_url,
"expiresAt": expires_at,
},
status=status.HTTP_200_OK,
)
@@ -0,0 +1,102 @@
# 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 json
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
import pytest
from plane.utils.nodedc_bim_gateway import (
BimGatewayError,
build_bim_embed_url,
get_bim_direct_model_type,
get_bim_model_type,
resolve_bim_viewer_model,
to_bim_relative_asset_url,
)
@pytest.mark.unit
def test_cad_extensions_are_normalized():
assert get_bim_model_type("part.STEP") == "step"
assert get_bim_model_type("part.stp") == "step"
assert get_bim_model_type("part.IGES") == "iges"
assert get_bim_model_type("part.igs") == "iges"
assert get_bim_direct_model_type("part.glb") == "gltf"
assert get_bim_direct_model_type("part.step") is None
@pytest.mark.unit
def test_only_managed_public_bim_sources_are_accepted(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
assert (
to_bim_relative_asset_url("https://bim.example/uploads/project/model.glb?version=2")
== "/uploads/project/model.glb?version=2"
)
assert to_bim_relative_asset_url("/data/project/model.xkt") == "/data/project/model.xkt"
assert to_bim_relative_asset_url("https://attacker.example/uploads/model.glb") is None
assert to_bim_relative_asset_url("/api/auth/session") is None
@pytest.mark.unit
def test_embed_grant_is_short_lived_and_bound_to_ops_context(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_SECRET", "test-embed-secret")
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180")
model = {
"url": "/uploads/project/model.glb",
"settingsSrc": "/uploads/project/model.glb",
"type": "gltf",
"name": "model.glb",
}
viewer_url, expires_at = build_bim_embed_url(
model,
SimpleNamespace(id="user-1"),
"workspace",
"project-1",
"issue-1",
"attachment-1",
)
query = parse_qs(urlsplit(viewer_url).query)
encoded_payload = query["token"][0].split(".", 1)[0]
encoded_payload += "=" * (-len(encoded_payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(encoded_payload).decode("utf-8"))
assert urlsplit(viewer_url).path == "/embed/tasker"
assert claims["sub"] == "user-1"
assert claims["workspace"] == "workspace"
assert claims["project"] == "project-1"
assert claims["issue"] == "issue-1"
assert claims["attachment"] == "attachment-1"
assert claims["model"] == model
assert expires_at - claims["iat"] == 180
@pytest.mark.unit
def test_viewer_model_rejects_unmanaged_or_unready_sources(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
with pytest.raises(BimGatewayError, match="still being prepared"):
resolve_bim_viewer_model(
{
"originalFilename": "part.step",
"previewAvailable": False,
"src": "https://bim.example/uploads/project/part.step",
"type": "step",
}
)
with pytest.raises(BimGatewayError, match="outside the managed BIM storage"):
resolve_bim_viewer_model(
{
"originalFilename": "model.glb",
"previewAvailable": True,
"src": "https://attacker.example/uploads/model.glb",
"type": "gltf",
}
)
@@ -0,0 +1,420 @@
# 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"]