diff --git a/backend/app/api/sitemap.py b/backend/app/api/sitemap.py index 34b315e..6f6f419 100644 --- a/backend/app/api/sitemap.py +++ b/backend/app/api/sitemap.py @@ -1,38 +1,60 @@ -"""Sitemap & robots.txt API: status check, regenerate endpoint. +"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend. -Follows the same pattern as the theme API — public GET for status, -admin-only POST to force regeneration. +Because this is a white-label app where each tenant has its own domain +(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated +on-the-fly from the station config — no static files needed. + +This matches the reviewer feedback: + 'This is a whitelabel app - this file may need to be dynaimcally generated...' + +Follows the same pattern as theme_export.py: content is derived from +KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL. """ from typing import Any, Dict -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, Response, status as http_status from app.auth import get_current_admin_user -from app.sitemap_export import get_sitemap_status, write_sitemap_files +from app.config import settings +from app.sitemap_export import build_robots_txt, build_sitemap_xml from app.user_models import User router = APIRouter() -@router.get("/status") -async def sitemap_status() -> Dict[str, Any]: - """Return sitemap/robots file status. Public endpoint.""" - return get_sitemap_status() +@router.get("/sitemap.xml") +async def get_sitemap() -> Response: + """Return a dynamically-generated sitemap.xml. Public endpoint. + + Uses KMTN_WEBSITE_URL so each tenant gets the correct domain. + """ + xml = build_sitemap_xml(settings.WEBSITE_URL) + return Response(content=xml, media_type="application/xml") -@router.post("/regenerate", status_code=200) +@router.get("/robots.txt") +async def get_robots() -> Response: + """Return a dynamically-generated robots.txt. Public endpoint. + + Uses KMTN_WEBSITE_URL so the Sitemap directive points to the + correct tenant domain. + """ + txt = build_robots_txt(settings.WEBSITE_URL) + return Response(content=txt, media_type="text/plain") + + +@router.post("/regenerate", status_code=http_status.HTTP_200_OK) async def regenerate_sitemap( _: User = Depends(get_current_admin_user), ) -> Dict[str, Any]: - """Force regenerate sitemap.xml and robots.txt. Admin only. + """Force regenerate the sitemap cache. Admin only. - Returns the paths of the newly written files. + In the dynamic model this is a no-op (files are generated per-request), + but kept for API consistency with the theme endpoint. """ - try: - return write_sitemap_files() - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to generate sitemap: {e}", - ) + return { + "status": "ok", + "note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL", + "base_url": settings.WEBSITE_URL, + } diff --git a/backend/app/main.py b/backend/app/main.py index 2485dfd..a72a6ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -183,14 +183,6 @@ async def lifespan(app: FastAPI): except Exception as e: print(f" WARNING: Failed to write theme.json on startup: {e}") - # Write sitemap.xml + robots.txt on startup (tenant-aware) - try: - from app.sitemap_export import write_sitemap_files - info = write_sitemap_files() - print(f" ✓ Sitemap + robots.txt written (base_url={info['base_url']})") - except Exception as e: - print(f" WARNING: Failed to write sitemap on startup: {e}") - # Check GeoLite2 database geo_db = settings.GEOLITE2_DB_PATH if os.path.exists(geo_db): diff --git a/backend/app/sitemap_export.py b/backend/app/sitemap_export.py index 7661921..13a7ec9 100644 --- a/backend/app/sitemap_export.py +++ b/backend/app/sitemap_export.py @@ -1,24 +1,13 @@ -"""Sitemap & robots.txt export: generate tenant-aware SEO files. +"""Sitemap & robots.txt generation: tenant-aware SEO content. Because this is a white-label app where each tenant has its own domain (set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated -at runtime from the station config — one static write per startup, -plus an optional admin-triggered regeneration. - -Follows the same pattern as theme_export.py: -- Atomic write (tmp + os.replace) -- Written to STATIC_FILES_DIR so Nginx serves them -- Uses WEBSITE_URL for per-tenant domain resolution +at runtime — no static files are needed. The FastAPI /api/sitemap/* +endpoints call these functions; Nginx proxies /sitemap.xml and +/robots.txt to those endpoints so search-engine crawlers hit the right +content at the root URL. """ -import logging -from datetime import datetime, timezone -from pathlib import Path - -from app.config import settings - -logger = logging.getLogger(__name__) - # Public routes included in the sitemap with their change frequency and priority. SEOMAP_ROUTES = [ {"path": "/", "changefreq": "daily", "priority": 1.0}, @@ -49,8 +38,9 @@ def build_sitemap_xml(base_url: str) -> str: ) -def build_robots_txt(sitemap_url: str) -> str: +def build_robots_txt(base_url: str) -> str: """Build a robots.txt string that references the correct sitemap URL.""" + sitemap = base_url.rstrip("/") + "/sitemap.xml" return ( "User-agent: *\n" "Allow: /\n" @@ -58,59 +48,5 @@ def build_robots_txt(sitemap_url: str) -> str: "Disallow: /admin\n" "Disallow: /login\n" "\n" - f"Sitemap: {sitemap_url}/sitemap.xml\n" + f"Sitemap: {sitemap}\n" ) - - -def get_sitemap_status() -> dict: - """Check if sitemap.xml and robots.txt exist on disk and return metadata.""" - base = Path(settings.STATIC_FILES_DIR) - result = {} - for name in ("sitemap.xml", "robots.txt"): - path = base / name - if not path.exists(): - result[name] = {"exists": False, "path": str(path)} - continue - stat = path.stat() - result[name] = { - "exists": True, - "path": str(path), - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), - } - return result - - -def write_sitemap_files() -> dict: - """Write sitemap.xml and robots.txt to the static files directory. - - Uses atomic write (write to .tmp, then os.replace) to prevent - Nginx from serving a partially-written file. - - Returns a status dict with file paths. - """ - base_url = settings.WEBSITE_URL - static_dir = Path(settings.STATIC_FILES_DIR) - static_dir.mkdir(parents=True, exist_ok=True) - - # sitemap.xml - sitemap_content = build_sitemap_xml(base_url) - sitemap_path = static_dir / "sitemap.xml" - tmp_path = sitemap_path.with_suffix(".tmp") - tmp_path.write_text(sitemap_content, encoding="utf-8") - tmp_path.replace(sitemap_path) - logger.info("Wrote sitemap.xml (%d bytes) to %s, base_url=%s", len(sitemap_content), sitemap_path, base_url) - - # robots.txt - robots_content = build_robots_txt(base_url) - robots_path = static_dir / "robots.txt" - tmp_path = robots_path.with_suffix(".tmp") - tmp_path.write_text(robots_content, encoding="utf-8") - tmp_path.replace(robots_path) - logger.info("Wrote robots.txt (%d bytes) to %s", len(robots_content), robots_path) - - return { - "sitemap.xml": str(sitemap_path), - "robots.txt": str(robots_path), - "base_url": base_url, - } diff --git a/nginx.conf.template b/nginx.conf.template index 8f3f265..742c83d 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -24,6 +24,24 @@ server { try_files $uri $uri/ /index.html; } + # Dynamic sitemap.xml + robots.txt — proxied to the backend so each + # white-label tenant gets their KMTN_WEBSITE_URL in the output. + location = /sitemap.xml { + proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location = /robots.txt { + proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + # Proxy API calls to the backend service location /api/ { proxy_pass __API_UPSTREAM__;