Compare commits
2
Commits
55be42ed99
...
2716765223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2716765223 | ||
|
|
4b123025e4 |
No files matched your search
@@ -1,35 +1,66 @@
|
|||||||
"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend.
|
"""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
|
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
|
(stored in StationConfig.website), sitemap.xml and robots.txt are
|
||||||
on-the-fly from the station config — no static files needed.
|
generated on-the-fly from the database — no static files needed.
|
||||||
|
|
||||||
This matches the reviewer feedback:
|
This matches the reviewer feedback:
|
||||||
'This is a whitelabel app - this file may need to be dynaimcally generated...'
|
'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
|
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 typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response, status as http_status
|
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.auth import get_current_admin_user
|
from app.auth import get_current_admin_user
|
||||||
from app.config import settings
|
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.sitemap_export import build_robots_txt, build_sitemap_xml
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/sitemap.xml")
|
||||||
async def get_sitemap() -> Response:
|
async def get_sitemap() -> Response:
|
||||||
"""Return a dynamically-generated sitemap.xml. Public endpoint.
|
"""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")
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
@@ -37,10 +68,11 @@ async def get_sitemap() -> Response:
|
|||||||
async def get_robots() -> Response:
|
async def get_robots() -> Response:
|
||||||
"""Return a dynamically-generated robots.txt. Public endpoint.
|
"""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.
|
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")
|
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),
|
In the dynamic model this is a no-op (files are generated per-request),
|
||||||
but kept for API consistency with the theme endpoint.
|
but kept for API consistency with the theme endpoint.
|
||||||
"""
|
"""
|
||||||
|
base_url = await _get_base_url()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL",
|
"note": "sitemap and robots.txt are dynamically generated per-request from StationConfig.website",
|
||||||
"base_url": settings.WEBSITE_URL,
|
"base_url": base_url,
|
||||||
}
|
}
|
||||||
@@ -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
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.sitemap import _build_base_url
|
||||||
from app.main import app
|
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:
|
class TestSitemapEndpoints:
|
||||||
"""Tests for sitemap API routes."""
|
"""Tests for sitemap API routes."""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user