diff --git a/backend/app/api/links.py b/backend/app/api/links.py index f3bc153..c840ca7 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -1,3 +1,5 @@ +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -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"]) @@ -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): @@ -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, @@ -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} diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index e9dc6aa..a6ef018 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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), } diff --git a/backend/app/api/nodes.py b/backend/app/api/nodes.py index 21eed9c..4a4712d 100644 --- a/backend/app/api/nodes.py +++ b/backend/app/api/nodes.py @@ -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): @@ -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, @@ -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() @@ -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} @@ -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} @@ -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 @@ -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} diff --git a/backend/app/api/validation.py b/backend/app/api/validation.py new file mode 100644 index 0000000..0b84899 --- /dev/null +++ b/backend/app/api/validation.py @@ -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)] diff --git a/backend/app/auth/oauth.py b/backend/app/auth/oauth.py index 484790c..ab325d0 100644 --- a/backend/app/auth/oauth.py +++ b/backend/app/auth/oauth.py @@ -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="/") diff --git a/backend/app/main.py b/backend/app/main.py index 7beea6e..58491de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,10 @@ import logging from contextlib import asynccontextmanager +from urllib.parse import urlsplit from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from starlette.middleware.sessions import SessionMiddleware from app.config import get_settings @@ -58,6 +60,31 @@ async def lifespan(app: FastAPI): ) +def _origin(value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return "" + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" + + +trusted_origins = {_origin(origin) for origin in cors_origins} +trusted_origins.add(_origin(settings.app_base_url)) +trusted_origins.discard("") + + +@app.middleware("http") +async def enforce_trusted_origin(request: Request, call_next): + """Reject cross-origin writes that could ride an authenticated session.""" + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + origin = request.headers.get("origin") + if origin and _origin(origin) not in trusted_origins: + return JSONResponse( + status_code=403, + content={"detail": "Untrusted request origin"}, + ) + return await call_next(request) + + # Security headers middleware @app.middleware("http") async def security_headers(request: Request, call_next): @@ -79,7 +106,8 @@ async def audit_log(request: Request, call_next): response = await call_next(request) # Log write operations if request.method in ("POST", "PUT", "PATCH", "DELETE"): - user = request.session.get("user", {}) if hasattr(request, "session") else {} + session = request.scope.get("session", {}) + user = session.get("user", {}) email = user.get("email", "anonymous") logger.info( "AUDIT %s %s %s -> %s", diff --git a/backend/tests/test_editor.py b/backend/tests/test_editor.py index 7e92073..5f014cb 100644 --- a/backend/tests/test_editor.py +++ b/backend/tests/test_editor.py @@ -293,3 +293,212 @@ async def fake_batch(port_ids): resp = await client.get(f"/api/datasources/traffic/live?map_id={map_id}") data = resp.json() assert data[link_id] == {"in_bps": 0, "out_bps": 0, "in_pct": 0, "out_pct": 0} + + +# ── Editor integrity and persistence regressions ─────────────────────── + + +@pytest.mark.anyio +async def test_node_nullable_and_layer_fields_persist(client: AsyncClient): + map_id = await _make_map(client) + resp = await client.post( + f"/api/maps/{map_id}/nodes", + json={ + "name": "editable", + "node_type": "router", + "width": 120, + "observium_device_id": 42, + "info_url": "https://example.net/device/42", + }, + ) + node_id = resp.json()["id"] + + resp = await client.put( + f"/api/maps/{map_id}/nodes/{node_id}", + json={ + "locked": True, + "z_order": 999, + "icon": "RTR", + "width": None, + "observium_device_id": None, + "info_url": None, + }, + ) + assert resp.status_code == 200 + + data = (await client.get(f"/api/maps/{map_id}")).json() + node = next(node for node in data["nodes"] if node["id"] == node_id) + assert node["locked"] is True + assert node["z_order"] == 999 + assert node["icon"] == "RTR" + assert node["width"] is None + assert node["observium_device_id"] is None + assert node["info_url"] is None + + +@pytest.mark.anyio +async def test_link_nullable_and_visual_fields_persist(client: AsyncClient): + map_id = await _make_map(client) + node_a = await _make_node(client, map_id, "A") + node_b = await _make_node(client, map_id, "B") + resp = await client.post( + f"/api/maps/{map_id}/links", + json={ + "name": "editable", + "source_id": node_a, + "target_id": node_b, + "source_anchor": "E", + "observium_port_id_a": 100, + "info_url_in": "https://example.net/graph", + }, + ) + link_id = resp.json()["id"] + + resp = await client.put( + f"/api/maps/{map_id}/links/{link_id}", + json={ + "source_anchor": None, + "observium_port_id_a": None, + "info_url_in": None, + "duplex": "half", + "z_order": 777, + "arrow_style": "none", + "via_style": "angled", + "via_points": [{"x": 10, "y": 20}], + }, + ) + assert resp.status_code == 200 + + data = (await client.get(f"/api/maps/{map_id}")).json() + link = next(link for link in data["links"] if link["id"] == link_id) + assert link["source_anchor"] is None + assert link["observium_port_id_a"] is None + assert link["info_url_in"] is None + assert link["duplex"] == "half" + assert link["z_order"] == 777 + assert link["arrow_style"] == "none" + assert link["via_style"] == "angled" + assert link["via_points"] == [{"x": 10, "y": 20}] + + +@pytest.mark.anyio +async def test_editor_urls_reject_unsafe_schemes(client: AsyncClient): + map_id = await _make_map(client) + resp = await client.post( + f"/api/maps/{map_id}/nodes", + json={"name": "unsafe", "info_url": "javascript:alert(1)"}, + ) + assert resp.status_code == 422 + + node_a = await _make_node(client, map_id, "A") + node_b = await _make_node(client, map_id, "B") + resp = await client.post( + f"/api/maps/{map_id}/links", + json={ + "name": "unsafe", + "source_id": node_a, + "target_id": node_b, + "info_url_out": "data:text/html,unsafe", + }, + ) + assert resp.status_code == 422 + + resp = await client.post( + f"/api/maps/{map_id}/links", + json={ + "name": "bad point", + "source_id": node_a, + "target_id": node_b, + "via_points": [{"x": 10}], + }, + ) + assert resp.status_code == 422 + + +@pytest.mark.anyio +async def test_parent_must_be_group_in_same_map_and_acyclic(client: AsyncClient): + map_a = await _make_map(client) + map_b = await _make_map(client) + child = await _make_node(client, map_a, "child") + non_group = await _make_node(client, map_a, "router") + foreign_group_resp = await client.post( + f"/api/maps/{map_b}/nodes", + json={"name": "foreign", "node_type": "group"}, + ) + foreign_group = foreign_group_resp.json()["id"] + + resp = await client.put( + f"/api/maps/{map_a}/nodes/{child}", json={"parent_id": non_group} + ) + assert resp.status_code == 422 + resp = await client.put( + f"/api/maps/{map_a}/nodes/{child}", json={"parent_id": foreign_group} + ) + assert resp.status_code == 422 + + group_1_resp = await client.post( + f"/api/maps/{map_a}/nodes", json={"name": "g1", "node_type": "group"} + ) + group_2_resp = await client.post( + f"/api/maps/{map_a}/nodes", json={"name": "g2", "node_type": "group"} + ) + group_1 = group_1_resp.json()["id"] + group_2 = group_2_resp.json()["id"] + + resp = await client.put( + f"/api/maps/{map_a}/nodes/{group_2}", json={"parent_id": group_1} + ) + assert resp.status_code == 200 + resp = await client.put( + f"/api/maps/{map_a}/nodes/{group_1}", json={"parent_id": group_2} + ) + assert resp.status_code == 422 + assert "cycle" in resp.json()["detail"].lower() + + resp = await client.put( + f"/api/maps/{map_a}/nodes/{group_1}", json={"node_type": "router"} + ) + assert resp.status_code == 422 + resp = await client.patch( + f"/api/maps/{map_a}/nodes/batch", + json={"node_ids": [group_1], "fields": {"node_type": "router"}}, + ) + assert resp.status_code == 422 + + resp = await client.put( + f"/api/maps/{map_a}/nodes/{child}", json={"parent_id": group_1} + ) + assert resp.status_code == 200 + resp = await client.put( + f"/api/maps/{map_a}/nodes/{child}", json={"parent_id": None} + ) + assert resp.status_code == 200 + data = (await client.get(f"/api/maps/{map_a}")).json() + saved_child = next(node for node in data["nodes"] if node["id"] == child) + assert saved_child["parent_id"] is None + + +@pytest.mark.anyio +async def test_delete_node_removes_links_and_detaches_children(client: AsyncClient): + map_id = await _make_map(client) + group_resp = await client.post( + f"/api/maps/{map_id}/nodes", json={"name": "group", "node_type": "group"} + ) + group_id = group_resp.json()["id"] + child_resp = await client.post( + f"/api/maps/{map_id}/nodes", + json={"name": "child", "node_type": "router", "parent_id": group_id}, + ) + child_id = child_resp.json()["id"] + link_resp = await client.post( + f"/api/maps/{map_id}/links", + json={"name": "link", "source_id": group_id, "target_id": child_id}, + ) + assert link_resp.status_code == 200 + + resp = await client.delete(f"/api/maps/{map_id}/nodes/{group_id}") + assert resp.status_code == 200 + data = (await client.get(f"/api/maps/{map_id}")).json() + saved_child = next(node for node in data["nodes"] if node["id"] == child_id) + assert saved_child["parent_id"] is None + assert data["links"] == [] diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 98bcd59..d6ca83d 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -17,6 +17,7 @@ def anyio_backend(): async def client(): from app.main import app from app.models.database import init_db + await init_db() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: @@ -62,3 +63,21 @@ async def test_maps_crud(client: AsyncClient): # Verify deleted resp = await client.get(f"/api/maps/{map_id}") assert resp.status_code == 404 + + +@pytest.mark.anyio +async def test_write_requests_reject_untrusted_origins(client: AsyncClient): + resp = await client.post( + "/api/maps", + headers={"Origin": "https://attacker.example"}, + json={"name": "Should not exist"}, + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Untrusted request origin" + + resp = await client.post( + "/api/maps", + headers={"Origin": "http://localhost:5173"}, + json={"name": "Allowed origin"}, + ) + assert resp.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index a973583..84b5145 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,9 @@ services: # React frontend with Vite dev server (HMR) frontend: - build: ./frontend + build: + context: ./frontend + target: development ports: - "5173:5173" volumes: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..2a0d137 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +*.log +*.tsbuildinfo diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c673d93..ecdce9d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,25 @@ -FROM node:26-alpine +FROM node:26-alpine AS development WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install +COPY package.json package-lock.json ./ +RUN npm ci COPY . . EXPOSE 5173 -CMD ["npm", "run", "dev"] +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + +FROM development AS build + +RUN npm run build + +FROM nginx:1.29-alpine AS production + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..4729bab --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src 'self' https://fonts.bunny.net; img-src 'self' data: blob:; connect-src 'self'" always; + + location /assets/ { + try_files $uri =404; + expires 1y; + } + + location / { + try_files $uri $uri/ /index.html; + expires -1; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 751daa6..9c2b69b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "html-to-image": "^1.11.13", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.4", + "react-router-dom": "^7.18.2", "recharts": "^2.12.0", "zustand": "^4.5.0" }, @@ -109,9 +109,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -159,9 +159,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -291,15 +291,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -393,9 +384,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -413,9 +401,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -433,9 +418,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -453,9 +435,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -473,9 +452,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -493,9 +469,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1138,9 +1111,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -1342,6 +1315,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1791,9 +1777,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2101,9 +2087,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2358,9 +2344,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -2568,9 +2554,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2592,9 +2575,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2616,9 +2596,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2640,9 +2617,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2819,9 +2793,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3296,35 +3270,41 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "react-router": "7.18.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/react-smooth": { @@ -3551,6 +3531,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b7708fb..a2353d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "html-to-image": "^1.11.13", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.4", + "react-router-dom": "^7.18.2", "recharts": "^2.12.0", "zustand": "^4.5.0" }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4b54548..df2c229 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -77,7 +77,7 @@ export const api = { batchMoveNodes: (mapId: string, moves: Array<{ id: string; x: number; y: number }>) => request<{ ok: boolean }>(`/api/maps/${encodeURIComponent(mapId)}/nodes/batch-move`, { method: "POST", body: JSON.stringify({ moves }) }), batchUpdateNodes: (mapId: string, ids: string[], fields: Record) => - request(`/api/maps/${encodeURIComponent(mapId)}/nodes/batch`, { + request<{ nodes: MapNode[] }>(`/api/maps/${encodeURIComponent(mapId)}/nodes/batch`, { method: "PATCH", body: JSON.stringify({ node_ids: ids, fields }), }), @@ -90,7 +90,7 @@ export const api = { deleteLink: (mapId: string, linkId: string) => request<{ ok: boolean }>(`/api/maps/${encodeURIComponent(mapId)}/links/${encodeURIComponent(linkId)}`, { method: "DELETE" }), batchUpdateLinks: (mapId: string, ids: string[], fields: Record) => - request(`/api/maps/${encodeURIComponent(mapId)}/links/batch`, { + request<{ links: MapLink[] }>(`/api/maps/${encodeURIComponent(mapId)}/links/batch`, { method: "PATCH", body: JSON.stringify({ link_ids: ids, fields }), }), @@ -109,10 +109,6 @@ export const api = { getTrafficHistoryByPort: (portId: number, mapId: string, start?: string, end?: string) => request(`/api/datasources/traffic/history/by-port?${qs({ port_id: String(portId), map_id: mapId, start: start || "-24h", end: end || "now" })}`), - // AI - generateMap: (data: { device_ids: number[]; instructions: string; map_id?: string }) => - request>("/api/ai/generate-map", { method: "POST", body: JSON.stringify(data) }), - // Auth getUser: () => request<{ sub: string; name: string; email: string }>("/auth/me"), diff --git a/frontend/src/components/Editor/MapEditor.tsx b/frontend/src/components/Editor/MapEditor.tsx deleted file mode 100644 index 1eacd00..0000000 --- a/frontend/src/components/Editor/MapEditor.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState } from "react"; -import { api } from "@/api/client"; -import { useMapStore } from "@/hooks/useMapStore"; -import type { NodeType } from "@/types"; - -const NODE_TYPES: { value: NodeType; label: string; badge: string }[] = [ - { value: "router", label: "Router", badge: "RTR" }, - { value: "switch_l3", label: "Switch L3", badge: "L3" }, - { value: "switch_l2", label: "Switch L2", badge: "L2" }, - { value: "server", label: "Server", badge: "SRV" }, - { value: "firewall", label: "Firewall", badge: "FW" }, - { value: "ix", label: "IX Peering", badge: "IX" }, - { value: "transit", label: "Transit", badge: "TR" }, - { value: "pni", label: "PNI", badge: "PNI" }, - { value: "provider", label: "Provider", badge: "PRV" }, - { value: "cloud", label: "Cloud", badge: "CLD" }, - { value: "internet", label: "External", badge: "NET" }, - { value: "group", label: "Group / Site", badge: "GRP" }, -]; - -const BADGE_COLORS: Record = { - router: "text-node-router", - switch_l3: "text-node-switch-l3", - switch_l2: "text-node-switch-l2", - server: "text-node-server", - firewall: "text-node-firewall", - cloud: "text-node-cloud", - internet: "text-node-internet", - group: "text-noc-text-muted", -}; - -export function MapEditor() { - const { map, editMode, loadMap } = useMapStore(); - const [aiPrompt, setAiPrompt] = useState(""); - const [aiLoading, setAiLoading] = useState(false); - const [aiError, setAiError] = useState(null); - - if (!editMode || !map) return null; - - const handleAddNode = async (nodeType: NodeType, label: string) => { - try { - await api.createNode(map.id, { - name: `new-${nodeType}`, - label, - node_type: nodeType, - x: 400 + Math.random() * 200, - y: 300 + Math.random() * 200, - ...(nodeType === "group" ? { width: 400, height: 300 } : {}), - }); - await loadMap(map.id); - } catch (e) { - console.error("Failed to create node:", e); - } - }; - - const handleAiGenerate = async () => { - if (!aiPrompt.trim()) return; - setAiLoading(true); - setAiError(null); - try { - await api.generateMap({ - device_ids: [], - instructions: aiPrompt, - map_id: map.id, - }); - await loadMap(map.id); - } catch { - setAiError("Generation failed. Check API key and try again."); - } finally { - setAiLoading(false); - } - }; - - return ( -
- {/* Header */} -
-
- - - - Editor -
-
- -
- {/* Add Node */} -
-
Add Node
-
- {NODE_TYPES.map((nt) => ( - - ))} -
-
- - {/* Separator */} -
- - {/* AI Layout */} -
-
- - - - AI Layout -
-