diff --git a/backend/app/api/sitemap.py b/backend/app/api/sitemap.py index 6f6f419..e887a03 100644 --- a/backend/app/api/sitemap.py +++ b/backend/app/api/sitemap.py @@ -1,35 +1,66 @@ """Sitemap & robots.txt API: tenant-aware SEO files served by the backend. 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. +(stored in StationConfig.website), sitemap.xml and robots.txt are +generated on-the-fly from the database — 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. +the station config at runtime, so every tenant gets their own correct URL. """ from typing import Any, Dict from fastapi import APIRouter, Depends, Response, status as http_status +from sqlalchemy import select from app.auth import get_current_admin_user from app.config import settings +from app.database import async_session +from app.models import StationConfig from app.sitemap_export import build_robots_txt, build_sitemap_xml from app.user_models import User router = APIRouter() +def _build_base_url(website: str) -> str: + """Build the canonical base URL from the station website field. + + The website column stores the bare domain (e.g. 'kmountainflower.org'), + so we prefix with https:// to produce a valid base URL. + """ + domain = website.strip().removesuffix("/") + # The website field may or may not include a scheme; if it already + # has one, use it as-is, otherwise default to https. + if "://" in domain: + return domain + return f"https://{domain}" + + +async def _get_base_url() -> str: + """Fetch the base URL from the station config in the database.""" + async with async_session() as session: + result = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + config = result.scalar_one_or_none() + if config is None: + # Fall back to env config if DB is empty (e.g. before seeding) + return settings.WEBSITE_URL + return _build_base_url(config.website) + + @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. + Uses StationConfig.website so each tenant gets the correct domain. """ - xml = build_sitemap_xml(settings.WEBSITE_URL) + base_url = await _get_base_url() + xml = build_sitemap_xml(base_url) return Response(content=xml, media_type="application/xml") @@ -37,10 +68,11 @@ async def get_sitemap() -> Response: async def get_robots() -> Response: """Return a dynamically-generated robots.txt. Public endpoint. - Uses KMTN_WEBSITE_URL so the Sitemap directive points to the + Uses StationConfig.website so the Sitemap directive points to the correct tenant domain. """ - txt = build_robots_txt(settings.WEBSITE_URL) + base_url = await _get_base_url() + txt = build_robots_txt(base_url) return Response(content=txt, media_type="text/plain") @@ -53,8 +85,9 @@ async def regenerate_sitemap( In the dynamic model this is a no-op (files are generated per-request), but kept for API consistency with the theme endpoint. """ + base_url = await _get_base_url() return { "status": "ok", - "note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL", - "base_url": settings.WEBSITE_URL, + "note": "sitemap and robots.txt are dynamically generated per-request from StationConfig.website", + "base_url": base_url, } diff --git a/backend/tests/test_sitemap_api.py b/backend/tests/test_sitemap_api.py index 40720c4..40a82e6 100644 --- a/backend/tests/test_sitemap_api.py +++ b/backend/tests/test_sitemap_api.py @@ -1,12 +1,38 @@ -"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate.""" +"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate. + +Note: The sitemap and robots.txt endpoints now read the site URL from +StationConfig.website in the database (falling back to KMTN_WEBSITE_URL +if the config is missing). These tests assume a seeded database with the +default station config (website = 'kmountainflower.org'). +""" import pytest from httpx import AsyncClient from starlette.testclient import TestClient +from app.api.sitemap import _build_base_url from app.main import app +class TestBuildBaseUrl: + """Tests for the _build_base_url helper.""" + + def test_bare_domain_gets_https(self): + assert _build_base_url("kmountainflower.org") == "https://kmountainflower.org" + + def test_domain_with_trailing_slash(self): + assert _build_base_url("kmountainflower.org/") == "https://kmountainflower.org" + + def test_domain_with_whitespace(self): + assert _build_base_url(" mystation.org ") == "https://mystation.org" + + def test_already_has_https_scheme(self): + assert _build_base_url("https://kmountainflower.org") == "https://kmountainflower.org" + + def test_already_has_http_scheme(self): + assert _build_base_url("http://kmountainflower.org") == "http://kmountainflower.org" + + class TestSitemapEndpoints: """Tests for sitemap API routes."""