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
62 changes: 48 additions & 14 deletions backend/app/api/links.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -8,6 +10,7 @@
from app.auth.oauth import get_current_user
from app.auth.guards import require_map_owner
from app.api.maps import _serialize_link
from app.api.validation import SafeHttpUrl

router = APIRouter(prefix="/api/maps/{map_id}/links", tags=["links"])

Expand All @@ -33,45 +36,58 @@ async def _validate_endpoints(
raise HTTPException(422, "target_id does not reference a node in this map")


class ViaPoint(BaseModel):
x: float = Field(..., ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
y: float = Field(..., ge=-10_000_000, le=10_000_000, allow_inf_nan=False)


class LinkCreate(BaseModel):
name: str = Field(..., max_length=255)
name: str = Field(..., min_length=1, max_length=255)
link_type: LinkType = LinkType.INTERNAL
source_id: str
target_id: str
source_anchor: str | None = Field(None, max_length=50)
target_anchor: str | None = Field(None, max_length=50)
bandwidth: float = 1_000_000_000
bandwidth: float = Field(
1_000_000_000, gt=0, le=1_000_000_000_000_000, allow_inf_nan=False
)
bandwidth_label: str = Field("1G", max_length=20)
via_points: list[dict] = Field(default_factory=list)
via_style: str = "curved"
via_points: list[ViaPoint] = Field(default_factory=list, max_length=100)
via_style: Literal["curved", "angled"] = "curved"
width: int = Field(4, ge=1, le=50)
arrow_style: Literal["classic", "standard", "none"] = "classic"
duplex: Literal["full", "half"] = "full"
datasource: dict = Field(
default_factory=lambda: {"type": "static", "in": 0, "out": 0}
)
observium_port_id_a: int | None = None
observium_port_id_b: int | None = None
info_url_in: str | None = Field(None, max_length=512)
info_url_out: str | None = Field(None, max_length=512)
info_url_in: SafeHttpUrl | None = Field(None, max_length=512)
info_url_out: SafeHttpUrl | None = Field(None, max_length=512)
extra: dict = Field(default_factory=dict)


class LinkUpdate(BaseModel):
name: str | None = Field(None, max_length=255)
name: str | None = Field(None, min_length=1, max_length=255)
link_type: LinkType | None = None
source_anchor: str | None = Field(None, max_length=50)
target_anchor: str | None = Field(None, max_length=50)
bandwidth: float | None = None
bandwidth: float | None = Field(
None, gt=0, le=1_000_000_000_000_000, allow_inf_nan=False
)
bandwidth_label: str | None = Field(None, max_length=20)
via_points: list[dict] | None = None
via_style: str | None = None
arrow_style: str | None = Field(None, max_length=20)
via_points: list[ViaPoint] | None = Field(None, max_length=100)
via_style: Literal["curved", "angled"] | None = None
arrow_style: Literal["classic", "standard", "none"] | None = None
duplex: Literal["full", "half"] | None = None
width: int | None = Field(None, ge=1, le=50)
datasource: dict | None = None
observium_port_id_a: int | None = None
observium_port_id_b: int | None = None
info_url_in: str | None = Field(None, max_length=512)
info_url_out: str | None = Field(None, max_length=512)
info_url_in: SafeHttpUrl | None = Field(None, max_length=512)
info_url_out: SafeHttpUrl | None = Field(None, max_length=512)
extra: dict | None = None
z_order: int | None = Field(None, ge=-100_000, le=100_000)


class LinkBatchFields(BaseModel):
Expand All @@ -85,6 +101,24 @@ class LinkBatchUpdate(BaseModel):
fields: LinkBatchFields


_NULLABLE_UPDATE_FIELDS = {
"source_anchor",
"target_anchor",
"observium_port_id_a",
"observium_port_id_b",
"info_url_in",
"info_url_out",
}


def _link_updates(data: LinkUpdate) -> dict:
"""Keep explicit nulls for nullable columns while ignoring nulls elsewhere."""
updates = data.model_dump(exclude_none=True)
for field in data.model_fields_set & _NULLABLE_UPDATE_FIELDS:
updates[field] = getattr(data, field)
return updates


@router.post("")
async def create_link(
map_id: str,
Expand Down Expand Up @@ -119,7 +153,7 @@ async def update_link(
# Endpoints are not mutable via LinkUpdate; validate the effective endpoints
# to guarantee the link stays consistent (both ends in this map, no self-link).
await _validate_endpoints(db, map_id, link.source_id, link.target_id)
for field, value in data.model_dump(exclude_none=True).items():
for field, value in _link_updates(data).items():
setattr(link, field, value)
await db.commit()
return {"ok": True}
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ def _serialize_node(n: Node) -> dict:
"style": n.style,
"info_url": n.info_url,
"extra": n.extra,
"locked": bool(n.locked),
}


Expand Down
147 changes: 122 additions & 25 deletions backend/app/api/nodes.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field

from app.models import Node, get_db
from app.models import Link, Node, get_db
from app.models.node import NodeType
from app.auth.oauth import get_current_user
from app.auth.guards import require_map_owner
from app.api.maps import _serialize_node
from app.api.validation import SafeHttpUrl

router = APIRouter(prefix="/api/maps/{map_id}/nodes", tags=["nodes"])


class NodeCreate(BaseModel):
name: str = Field(..., max_length=255)
name: str = Field(..., min_length=1, max_length=255)
label: str = Field("", max_length=255)
node_type: NodeType = NodeType.SWITCH_L2
x: float = 0
y: float = 0
x: float = Field(0, ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
y: float = Field(0, ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
parent_id: str | None = None
width: float | None = None
height: float | None = None
width: float | None = Field(None, ge=1, le=10_000, allow_inf_nan=False)
height: float | None = Field(None, ge=1, le=10_000, allow_inf_nan=False)
observium_device_id: int | None = None
style: dict = Field(default_factory=dict)
info_url: str | None = Field(None, max_length=512)
info_url: SafeHttpUrl | None = Field(None, max_length=512)
extra: dict = Field(default_factory=dict)


class NodeUpdate(BaseModel):
name: str | None = Field(None, max_length=255)
name: str | None = Field(None, min_length=1, max_length=255)
label: str | None = Field(None, max_length=255)
node_type: NodeType | None = None
x: float | None = None
y: float | None = None
x: float | None = Field(None, ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
y: float | None = Field(None, ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
z_order: int | None = Field(None, ge=-100_000, le=100_000)
parent_id: str | None = None
width: float | None = None
height: float | None = None
width: float | None = Field(None, ge=1, le=10_000, allow_inf_nan=False)
height: float | None = Field(None, ge=1, le=10_000, allow_inf_nan=False)
observium_device_id: int | None = None
icon: str | None = Field(None, max_length=512)
locked: bool | None = None
style: dict | None = None
info_url: str | None = Field(None, max_length=512)
info_url: SafeHttpUrl | None = Field(None, max_length=512)
extra: dict | None = None


class NodeMove(BaseModel):
id: str
x: float
y: float
x: float = Field(..., ge=-10_000_000, le=10_000_000, allow_inf_nan=False)
y: float = Field(..., ge=-10_000_000, le=10_000_000, allow_inf_nan=False)


class NodeBatchMove(BaseModel):
Expand All @@ -66,6 +69,59 @@ class NodeBatchUpdate(BaseModel):
fields: NodeBatchFields


_NULLABLE_UPDATE_FIELDS = {
"parent_id",
"width",
"height",
"observium_device_id",
"icon",
"info_url",
}


def _node_updates(data: NodeUpdate) -> dict:
"""Keep explicit nulls for nullable columns while ignoring nulls elsewhere."""
updates = data.model_dump(exclude_none=True)
for field in data.model_fields_set & _NULLABLE_UPDATE_FIELDS:
updates[field] = getattr(data, field)
return updates


async def _validate_parent(
db: AsyncSession,
map_id: str,
parent_id: str | None,
node_id: str | None = None,
) -> None:
"""Ensure containment stays map-local, targets a group, and is acyclic."""
if parent_id is None:
return
if parent_id == node_id:
raise HTTPException(422, "A node cannot be its own parent")

result = await db.execute(
select(Node.id, Node.parent_id, Node.node_type).where(Node.map_id == map_id)
)
rows = result.all()
nodes = {row.id: row for row in rows}
parent = nodes.get(parent_id)
if parent is None:
raise HTTPException(422, "parent_id does not reference a node in this map")
if parent.node_type != NodeType.GROUP:
raise HTTPException(422, "parent_id must reference a group node")

seen: set[str] = set()
current_id: str | None = parent_id
while current_id is not None:
if current_id == node_id:
raise HTTPException(422, "Parent assignment would create a cycle")
if current_id in seen:
raise HTTPException(422, "The existing parent hierarchy contains a cycle")
seen.add(current_id)
current = nodes.get(current_id)
current_id = current.parent_id if current else None


@router.post("")
async def create_node(
map_id: str,
Expand All @@ -74,6 +130,7 @@ async def create_node(
user=Depends(get_current_user),
):
await require_map_owner(map_id, user, db)
await _validate_parent(db, map_id, data.parent_id)
node = Node(map_id=map_id, **data.model_dump())
db.add(node)
await db.commit()
Expand All @@ -96,7 +153,21 @@ async def update_node(
node = result.scalar_one_or_none()
if not node:
raise HTTPException(404, "Node not found")
for field, value in data.model_dump(exclude_none=True).items():
updates = _node_updates(data)
if "parent_id" in updates:
await _validate_parent(db, map_id, updates["parent_id"], node_id)
if (
node.node_type == NodeType.GROUP
and updates.get("node_type", NodeType.GROUP) != NodeType.GROUP
):
child = await db.scalar(
select(Node.id)
.where(Node.map_id == map_id, Node.parent_id == node_id)
.limit(1)
)
if child is not None:
raise HTTPException(422, "A group with children cannot change node type")
for field, value in updates.items():
setattr(node, field, value)
await db.commit()
return {"ok": True}
Expand All @@ -116,6 +187,19 @@ async def delete_node(
node = result.scalar_one_or_none()
if not node:
raise HTTPException(404, "Node not found")
# Do not rely on database-level cascades: older SQLite deployments may
# otherwise retain orphaned links or children.
await db.execute(
delete(Link).where(
Link.map_id == map_id,
(Link.source_id == node_id) | (Link.target_id == node_id),
)
)
await db.execute(
update(Node)
.where(Node.map_id == map_id, Node.parent_id == node_id)
.values(parent_id=None)
)
await db.delete(node)
await db.commit()
return {"ok": True}
Expand All @@ -139,6 +223,18 @@ async def batch_update_nodes(
)
nodes = result.scalars().all()
f = data.fields
if f.node_type is not None and f.node_type != NodeType.GROUP:
group_ids = [node.id for node in nodes if node.node_type == NodeType.GROUP]
if group_ids:
child = await db.scalar(
select(Node.id)
.where(Node.map_id == map_id, Node.parent_id.in_(group_ids))
.limit(1)
)
if child is not None:
raise HTTPException(
422, "A group with children cannot change node type"
)
for node in nodes:
if f.node_type is not None:
node.node_type = f.node_type
Expand All @@ -165,13 +261,14 @@ async def batch_move_nodes(
):
"""Move multiple nodes at once (for drag-and-drop editor)."""
await require_map_owner(map_id, user, db)
for move in data.moves:
result = await db.execute(
select(Node).where(Node.id == move.id, Node.map_id == map_id)
)
node = result.scalar_one_or_none()
if node:
node.x = move.x
node.y = move.y
move_by_id = {move.id: move for move in data.moves}
result = await db.execute(
select(Node).where(Node.id.in_(move_by_id), Node.map_id == map_id)
)
nodes = result.scalars().all()
for node in nodes:
move = move_by_id[node.id]
node.x = move.x
node.y = move.y
await db.commit()
return {"ok": True}
16 changes: 16 additions & 0 deletions backend/app/api/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Annotated
from urllib.parse import urlsplit

from pydantic import AfterValidator


def _validate_http_url(value: str) -> str:
"""Allow only absolute HTTP(S) URLs in editor click-through fields."""
value = value.strip()
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("URL must be an absolute http:// or https:// URL")
return value


SafeHttpUrl = Annotated[str, AfterValidator(_validate_http_url)]
14 changes: 12 additions & 2 deletions backend/app/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,20 @@ async def callback(request: Request):
if not roles:
roles = _extract_roles(id_token_claims, settings.oauth_roles_claim)

subject = str(userinfo.get("sub") or "").strip()
email = str(
userinfo.get("email") or userinfo.get("preferred_username") or ""
).strip()
if not subject or not email:
logger.warning("OAuth identity is missing a subject or email")
raise HTTPException(
401, "Identity provider response is missing required claims"
)

request.session["user"] = {
"sub": userinfo.get("sub", ""),
"sub": subject,
"name": userinfo.get("name", ""),
"email": userinfo.get("email", ""),
"email": email,
"roles": roles,
}
return RedirectResponse(url="/")
Expand Down
Loading