Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions project/block_manager/migrations/0005_project_sharing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('block_manager', '0004_add_user_to_project'),
]

operations = [
migrations.AddField(
model_name='project',
name='share_token',
field=models.UUIDField(
blank=True,
db_index=True,
default=None,
help_text='Unique token for public sharing; generated on first share',
null=True,
unique=True,
),
),
migrations.AddField(
model_name='project',
name='is_shared',
field=models.BooleanField(
default=False,
help_text='Whether this project is publicly accessible via share link',
),
),
]
14 changes: 13 additions & 1 deletion project/block_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ class Project(models.Model):
choices=[('pytorch', 'PyTorch'), ('tensorflow', 'TensorFlow')],
default='pytorch'
)
share_token = models.UUIDField(
default=None,
null=True,
blank=True,
unique=True,
db_index=True,
help_text='Unique token for public sharing; generated on first share'
)
is_shared = models.BooleanField(
default=False,
help_text='Whether this project is publicly accessible via share link'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand All @@ -36,7 +48,7 @@ class Meta:

def __str__(self):
return self.name

class ModelArchitecture(models.Model):
"""Stores the architecture graph for a project"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Expand Down
6 changes: 4 additions & 2 deletions project/block_manager/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ class Meta:
model = Project
fields = [
'id', 'name', 'description', 'framework',
'share_token', 'is_shared',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']


class ProjectDetailSerializer(serializers.ModelSerializer):
Expand All @@ -222,9 +223,10 @@ class Meta:
model = Project
fields = [
'id', 'name', 'description', 'framework',
'share_token', 'is_shared',
'architecture', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']


class SaveArchitectureSerializer(serializers.Serializer):
Expand Down
12 changes: 12 additions & 0 deletions project/block_manager/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
from block_manager.views.export_views import export_model
from block_manager.views.chat_views import chat_message, get_suggestions, get_environment_info
from block_manager.views.group_views import group_definition_list, group_definition_detail
from block_manager.views.sharing_views import (
get_shared_project,
get_shared_architecture,
enable_sharing,
disable_sharing,
)

# Create router for viewsets
router = DefaultRouter()
Expand Down Expand Up @@ -47,4 +53,10 @@

# Environment info endpoint
path('environment', get_environment_info, name='environment-info'),

# Sharing endpoints
path('shared/<uuid:share_token>/', get_shared_project, name='shared-project'),
path('shared/<uuid:share_token>/architecture/', get_shared_architecture, name='shared-architecture'),
path('projects/<uuid:project_id>/share/', enable_sharing, name='enable-sharing'),
path('projects/<uuid:project_id>/unshare/', disable_sharing, name='disable-sharing'),
]
122 changes: 122 additions & 0 deletions project/block_manager/views/sharing_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import uuid
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.shortcuts import get_object_or_404

from block_manager.models import Project


@api_view(['GET'])
def get_shared_project(request, share_token):
"""
Public endpoint — returns project metadata for a shared project.
No authentication required.
Returns 404 if the token doesn't exist or sharing is disabled.
"""
try:
project = Project.objects.get(share_token=share_token, is_shared=True)
except Project.DoesNotExist:
return Response(
{'error': 'Shared project not found or link is no longer active'},
status=status.HTTP_404_NOT_FOUND
)

owner_display_name = None
if project.user:
owner_display_name = project.user.display_name or "Anonymous"
Comment on lines +26 to +27
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential information disclosure in shared project endpoint. The owner_display_name field at line 27 falls back to "Anonymous" when display_name is not set, but this reveals that a user exists even if they haven't set a display name. Consider using a consistent anonymization approach or omitting the field entirely when display_name is None/empty to avoid revealing user existence patterns.

Suggested change
if project.user:
owner_display_name = project.user.display_name or "Anonymous"
if project.user and project.user.display_name:
owner_display_name = project.user.display_name

Copilot uses AI. Check for mistakes.

return Response({
'name': project.name,
'description': project.description,
'framework': project.framework,
'owner_display_name': owner_display_name,
'share_token': str(project.share_token),
})
Comment on lines +10 to +35
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared project endpoints should be exempt from Firebase authentication in the middleware. Currently, the middleware will attempt to verify tokens for these public endpoints. Add /api/shared/ to the exempt_paths list in FirebaseAuthenticationMiddleware.process_request() to avoid unnecessary token verification overhead and potential edge-case failures for unauthenticated users accessing shared projects.

Copilot uses AI. Check for mistakes.


@api_view(['GET'])
def get_shared_architecture(request, share_token):
"""
Public endpoint — returns the canvas state for a shared project.
No authentication required.
Returns 404 if the token doesn't exist or sharing is disabled.
"""
try:
project = Project.objects.get(share_token=share_token, is_shared=True)
except Project.DoesNotExist:
return Response(
{'error': 'Shared project not found or link is no longer active'},
status=status.HTTP_404_NOT_FOUND
)

try:
architecture = project.architecture
except Exception:
return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})
Comment on lines +53 to +56
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception handling at line 53-56 is too broad and silently swallows errors. This catches all exceptions (including programming errors, database failures, etc.) and returns an empty architecture. This could hide real issues in production. Instead, catch specific exceptions like Project.architecture.RelatedObjectDoesNotExist or AttributeError and log unexpected exceptions before returning a fallback response. This will help with debugging and monitoring.

Copilot uses AI. Check for mistakes.

if architecture.canvas_state:
return Response(architecture.canvas_state)

return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})


@api_view(['POST'])
def enable_sharing(request, project_id):
"""
Enable public sharing for a project.
Authentication required; only the project owner can call this.
Generates a share_token on first use; reuses the existing token on subsequent calls
so the perma-link stays stable.
"""
if not hasattr(request, 'firebase_user') or not request.firebase_user:
return Response(
{'error': 'Authentication required'},
status=status.HTTP_401_UNAUTHORIZED
)

project = get_object_or_404(Project, pk=project_id)

if project.user != request.firebase_user:
return Response(
{'error': 'You do not have permission to share this project'},
status=status.HTTP_403_FORBIDDEN
)

if project.share_token is None:
project.share_token = uuid.uuid4()

project.is_shared = True
project.save(update_fields=['share_token', 'is_shared'])

return Response({
'share_token': str(project.share_token),
'is_shared': project.is_shared,
})


@api_view(['DELETE'])
def disable_sharing(request, project_id):
"""
Disable public sharing for a project.
Authentication required; only the project owner can call this.
The share_token is preserved so re-enabling restores the same URL.
"""
if not hasattr(request, 'firebase_user') or not request.firebase_user:
return Response(
{'error': 'Authentication required'},
status=status.HTTP_401_UNAUTHORIZED
)

project = get_object_or_404(Project, pk=project_id)

if project.user != request.firebase_user:
return Response(
{'error': 'You do not have permission to modify this project'},
status=status.HTTP_403_FORBIDDEN
)

project.is_shared = False
project.save(update_fields=['is_shared'])

return Response({'is_shared': False})
2 changes: 2 additions & 0 deletions project/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const ConfigPanel = lazy(() => import('./components/ConfigPanel'))
const ChatBot = lazy(() => import('./components/ChatBot'))
const LandingPage = lazy(() => import('./landing').then(module => ({ default: module.LandingPage })))
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })))
const SharedProjectCanvas = lazy(() => import('./components/SharedProjectCanvas'))

// Loading spinner component
function LoadingSpinner() {
Expand Down Expand Up @@ -163,6 +164,7 @@ function App() {
/>
<Route path="/project" element={<ProjectCanvas />} />
<Route path="/project/:projectId" element={<ProjectCanvas />} />
<Route path="/shared/:shareToken" element={<SharedProjectCanvas />} />
</Routes>
</Suspense>
)
Expand Down
65 changes: 42 additions & 23 deletions project/frontend/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ const nodeTypes = {
interface CanvasProps {
onDragStart: (type: string) => void
onRegisterAddNode: (handler: (blockType: string) => void) => void
readOnly?: boolean
}

function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (blockType: string) => void) => void }) {
function FlowCanvas({ onRegisterAddNode, readOnly = false }: { onRegisterAddNode: (handler: (blockType: string) => void) => void; readOnly?: boolean }) {
const {
nodes,
edges,
Expand Down Expand Up @@ -88,8 +89,9 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
// Validation is now triggered manually via the Validate button in Header
// Removed automatic validation on nodes/edges change

// Keyboard shortcuts for undo/redo/delete/group/expand
// Keyboard shortcuts for undo/redo/delete/group/expand (disabled in read-only mode)
useEffect(() => {
if (readOnly) return
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Ctrl (Windows/Linux) or Cmd (Mac)
const isMod = e.ctrlKey || e.metaKey
Expand Down Expand Up @@ -130,7 +132,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])
}, [readOnly, undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])

// Find a suitable position for a new node
const findAvailablePosition = useCallback(() => {
Expand Down Expand Up @@ -400,6 +402,22 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
[nodes, setNodes]
)

// In read-only mode we still need to propagate selection changes so that
// ReactFlow sets node.selected=true (which reveals the group-block expand button
// and drives ConfigPanel display). We filter out position/remove/add changes so
// nothing can mutate the graph structure.
const onNodesChangeReadOnly = useCallback(
(changes: any) => {
const allowed = changes.filter((c: any) =>
c.type === 'select' || c.type === 'dimensions' || c.type === 'reset'
)
if (allowed.length > 0) {
setNodes(applyNodeChanges(allowed, nodes))
}
},
[nodes, setNodes]
)

const onEdgesChange = useCallback(
(changes: any) => {
setEdges(applyEdgeChanges(changes, edges))
Expand Down Expand Up @@ -688,32 +706,33 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
return (
<div
className="flex-1 h-full"
onDrop={onDrop}
onDragOver={onDragOver}
onDrop={readOnly ? undefined : onDrop}
onDragOver={readOnly ? undefined : onDragOver}
>
<HistoryToolbar />
{!readOnly && <HistoryToolbar />}
<ReactFlow
nodes={nodesWithHandlers}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={isInteractive ? onNodeClick : undefined}
onEdgeClick={isInteractive ? onEdgeClick : undefined}
onPaneClick={isInteractive ? onPaneClick : undefined}
onPaneContextMenu={isInteractive ? onPaneContextMenu : undefined}
onNodeContextMenu={isInteractive ? onNodeContextMenu : undefined}
onReconnect={onReconnect}
edgesReconnectable={true}
onNodesChange={readOnly ? onNodesChangeReadOnly : onNodesChange}
onEdgesChange={readOnly ? undefined : onEdgesChange}
onConnect={readOnly ? undefined : onConnect}
onNodeClick={onNodeClick}
onEdgeClick={readOnly ? undefined : onEdgeClick}
onPaneClick={onPaneClick}
onPaneContextMenu={readOnly ? undefined : (isInteractive ? onPaneContextMenu : undefined)}
onNodeContextMenu={readOnly ? undefined : (isInteractive ? onNodeContextMenu : undefined)}
onReconnect={readOnly ? undefined : onReconnect}
edgesReconnectable={!readOnly}
nodeTypes={nodeTypes}
connectionLineComponent={CustomConnectionLine}
fitView
minZoom={0.5}
maxZoom={1.5}
nodesDraggable={isInteractive}
nodesConnectable={isInteractive}
elementsSelectable={isInteractive}
onInteractiveChange={setIsInteractive}
nodesDraggable={readOnly ? false : isInteractive}
nodesConnectable={readOnly ? false : isInteractive}
elementsSelectable={true}
deleteKeyCode={readOnly ? null : undefined}
onInteractiveChange={readOnly ? undefined : setIsInteractive}
defaultEdgeOptions={{
animated: true,
style: { stroke: '#6366f1', strokeWidth: 2 }
Expand Down Expand Up @@ -751,7 +770,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
position="bottom-right"
/>
</ReactFlow>
{contextMenu && (
{!readOnly && contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
Expand Down Expand Up @@ -803,10 +822,10 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
)
}

export default function Canvas({ onDragStart, onRegisterAddNode }: CanvasProps) {
export default function Canvas({ onDragStart, onRegisterAddNode, readOnly = false }: CanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvas onRegisterAddNode={onRegisterAddNode} />
<FlowCanvas onRegisterAddNode={onRegisterAddNode} readOnly={readOnly} />
</ReactFlowProvider>
)
}
Expand Down
Loading