fix: switch sitemap/robots to dynamic generation + Nginx proxy
CI Pipeline / backend-test (push) Successful in 8m58s
CI Pipeline / frontend-test (push) Successful in 29s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 8m56s
CI Pipeline / frontend-test (pull_request) Successful in 28s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-test (push) Successful in 8m58s
CI Pipeline / frontend-test (push) Successful in 29s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 8m56s
CI Pipeline / frontend-test (pull_request) Successful in 28s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
The reviewer noted the static sitemap.xml had a hardcoded domain in a white-label app. Instead of writing files to disk, we now serve sitemap.xml and robots.txt as dynamic API endpoints that read KMTN_WEBSITE_URL at runtime. - Nginx proxies /sitemap.xml and /robots.txt to the backend API so search crawlers reach them at root paths. - Removed the startup filesystem-write that would fail in Docker (backend can't write to Nginx container's static dir). - Simplified sitemap_export.py to pure generation functions (no I/O). - Added /api/sitemap/regenerate admin endpoint for consistency.
This commit is contained in:
1 parent
5484c761b4
commit
3dead2921a
4 files changed
+67
-99
No files matched your search
+41
-19
@@ -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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user