diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py
index a46f211..9969336 100644
--- a/src/meshcore_hub/api/routes/nodes.py
+++ b/src/meshcore_hub/api/routes/nodes.py
@@ -23,6 +23,7 @@ async def list_nodes(
),
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
+ role: Optional[str] = Query(None, description="Filter by role tag value"),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
@@ -59,6 +60,16 @@ async def list_nodes(
)
)
+ if role:
+ # Filter nodes that have a role tag with the specified value
+ query = query.where(
+ Node.id.in_(
+ select(NodeTag.node_id).where(
+ NodeTag.key == "role", NodeTag.value == role
+ )
+ )
+ )
+
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py
index fae3505..dc3752d 100644
--- a/src/meshcore_hub/web/app.py
+++ b/src/meshcore_hub/web/app.py
@@ -7,7 +7,7 @@ from typing import AsyncGenerator
import httpx
from fastapi import FastAPI, Request
-from fastapi.responses import HTMLResponse, PlainTextResponse
+from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -153,9 +153,70 @@ def create_app(
return {"status": "not_ready", "api": str(e)}
@app.get("/robots.txt", response_class=PlainTextResponse)
- async def robots_txt() -> str:
+ async def robots_txt(request: Request) -> str:
"""Serve robots.txt to control search engine crawling."""
- return "User-agent: *\nAllow: /\n"
+ base_url = str(request.base_url).rstrip("/")
+ return f"User-agent: *\nAllow: /\n\nSitemap: {base_url}/sitemap.xml\n"
+
+ @app.get("/sitemap.xml")
+ async def sitemap_xml(request: Request) -> Response:
+ """Generate dynamic sitemap including all node pages."""
+ base_url = str(request.base_url).rstrip("/")
+
+ # Static pages
+ static_pages = [
+ ("", "daily", "1.0"),
+ ("/network", "hourly", "0.9"),
+ ("/nodes", "hourly", "0.9"),
+ ("/advertisements", "hourly", "0.8"),
+ ("/messages", "hourly", "0.8"),
+ ("/map", "daily", "0.7"),
+ ("/members", "weekly", "0.6"),
+ ]
+
+ urls = []
+ for path, changefreq, priority in static_pages:
+ urls.append(
+ f" \n"
+ f" {base_url}{path}\n"
+ f" {changefreq}\n"
+ f" {priority}\n"
+ f" "
+ )
+
+ # Fetch infrastructure nodes for dynamic pages
+ try:
+ response = await request.app.state.http_client.get(
+ "/api/v1/nodes", params={"limit": 500, "role": "infra"}
+ )
+ if response.status_code == 200:
+ nodes = response.json().get("items", [])
+ for node in nodes:
+ public_key = node.get("public_key")
+ if public_key:
+ # Use 8-char prefix (route handles redirect to full key)
+ urls.append(
+ f" \n"
+ f" {base_url}/nodes/{public_key[:8]}\n"
+ f" daily\n"
+ f" 0.5\n"
+ f" "
+ )
+ else:
+ logger.warning(
+ f"Failed to fetch nodes for sitemap: {response.status_code}"
+ )
+ except Exception as e:
+ logger.warning(f"Failed to fetch nodes for sitemap: {e}")
+
+ xml = (
+ '\n'
+ '\n'
+ + "\n".join(urls)
+ + "\n"
+ )
+
+ return Response(content=xml, media_type="application/xml")
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(