not bad
This commit is contained in:
parent
e2a60e71a3
commit
003f38ea60
36 changed files with 1543 additions and 1287 deletions
|
|
@ -5,14 +5,16 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
|
@ -151,7 +153,44 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
|
||||
)
|
||||
|
||||
def _serve_file(file_path: Path) -> FileResponse:
|
||||
def _absolute_meta_urls(request: Request) -> tuple[str, str]:
|
||||
configured_public_url = os.environ.get("LIFE_TOWERS_PUBLIC_URL", "").strip()
|
||||
if configured_public_url:
|
||||
public_root = configured_public_url.rstrip("/") + "/"
|
||||
return public_root, f"{public_root}og-image.png"
|
||||
|
||||
parts = urlsplit(str(request.url))
|
||||
canonical_url = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", "", ""))
|
||||
|
||||
root_path = str(request.scope.get("root_path") or "").strip("/")
|
||||
og_image_path = f"/{root_path}/og-image.png" if root_path else "/og-image.png"
|
||||
og_image_url = urlunsplit((parts.scheme, parts.netloc, og_image_path, "", ""))
|
||||
return canonical_url, og_image_url
|
||||
|
||||
def _serve_index(file_path: Path, request: Request) -> HTMLResponse:
|
||||
canonical_url, og_image_url = _absolute_meta_urls(request)
|
||||
html = file_path.read_text(encoding="utf-8")
|
||||
html = html.replace(
|
||||
'href="/" data-dynamic-url="canonical"',
|
||||
f'href="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/" data-dynamic-url="canonical"',
|
||||
f'content="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
|
||||
)
|
||||
html = html.replace(
|
||||
'content="/og-image.png" data-dynamic-url="og-image"',
|
||||
f'content="{escape(og_image_url, quote=True)}" data-dynamic-url="og-image"',
|
||||
)
|
||||
|
||||
resp = HTMLResponse(html)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
def _serve_file(file_path: Path, request: Request) -> Response:
|
||||
if file_path.name == "index.html":
|
||||
return _serve_index(file_path, request)
|
||||
|
||||
resp = FileResponse(str(file_path))
|
||||
if HASHED_PATTERN.search(file_path.name):
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
|
|
@ -160,7 +199,7 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
return resp
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str) -> Response:
|
||||
async def spa_fallback(request: Request, full_path: str) -> Response:
|
||||
# API routes are handled by the API router (registered before this);
|
||||
# if execution reaches here for an /api/* path, it really is unknown.
|
||||
if full_path.startswith("api/"):
|
||||
|
|
@ -174,12 +213,12 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
|||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if candidate.is_file():
|
||||
return _serve_file(candidate)
|
||||
return _serve_file(candidate, request)
|
||||
|
||||
# SPA fallback to index.html.
|
||||
index = static_dir / "index.html"
|
||||
if index.is_file():
|
||||
return _serve_file(index)
|
||||
return _serve_file(index, request)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,73 @@ async def test_health(client: AsyncClient) -> None:
|
|||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "index.html").write_text(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||
</head>
|
||||
</html>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(static_dir / "og-image.png").write_bytes(b"fake png")
|
||||
monkeypatch.setenv("LIFE_TOWERS_STATIC_DIR", str(static_dir))
|
||||
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://towers.example",
|
||||
) as c:
|
||||
resp = await c.get("/tasks?utm_source=test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:url" content="https://towers.example/tasks" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta name="twitter:image" content="https://towers.example/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
monkeypatch.setenv("LIFE_TOWERS_PUBLIC_URL", "https://public.example/towers")
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="https://internal.example",
|
||||
) as c:
|
||||
resp = await c.get("/")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
'<link rel="canonical" href="https://public.example/towers/" '
|
||||
'data-dynamic-url="canonical" />'
|
||||
) in resp.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://public.example/towers/og-image.png" '
|
||||
'data-dynamic-url="og-image" />'
|
||||
) in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue