Make monitoring TG API-only

This commit is contained in:
Grendgi
2026-06-04 15:42:50 +03:00
parent b78d1eac02
commit 5cda374fb1
37 changed files with 26 additions and 2836 deletions

View File

@@ -3,14 +3,13 @@ from pathlib import Path
import structlog
import uvicorn
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.types import Scope
from parser_bot.access import require_admin, require_admin_network
from parser_bot.access import require_admin
from parser_bot.api.routes import router
from parser_bot.config import settings
from parser_bot.scheduler.poller import build_scheduler
@@ -25,22 +24,6 @@ structlog.configure(
)
log = structlog.get_logger()
STATIC_DIR = Path(__file__).parent / "web" / "static"
NOCACHE = {"Cache-Control": "no-cache, must-revalidate"}
class NoCacheStaticFiles(StaticFiles):
"""StaticFiles with Cache-Control: no-cache.
The browser still gets to validate via ETag/Last-Modified (304 is fine),
but it will not silently serve a stale JS bundle after a deploy.
"""
async def get_response(self, path: str, scope: Scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -52,7 +35,7 @@ async def lifespan(app: FastAPI):
"startup", poll_interval=settings.poll_interval_seconds, authorized=authorized
)
if not authorized:
log.warning("not_authorized", action="open /auth.html to log in")
log.warning("not_authorized", action="open monitoring-tg in portal")
try:
yield
finally:
@@ -61,22 +44,6 @@ async def lifespan(app: FastAPI):
log.info("shutdown")
def _serve_section_template(vertical_dir: str, page: str) -> FileResponse:
"""Resolve a section-scoped URL to a single shared template.
Sections are dynamic (created via UI), so `/real-estate/dubai/channels.html`
can't be a real file. We serve `web/static/<vertical_dir>/section/<page>`
for any section slug — the section name is read from the URL by JS.
"""
target_name = page if page else "index.html"
if "/" in target_name or target_name.startswith(".."):
raise HTTPException(404)
target = STATIC_DIR / vertical_dir / "section" / target_name
if not target.is_file():
raise HTTPException(404)
return FileResponse(target, headers=NOCACHE)
def create_app() -> FastAPI:
public_base = settings.public_base_path.rstrip("/")
# Disable the default /docs, /redoc and /openapi.json — we serve our own
@@ -95,26 +62,8 @@ def create_app() -> FastAPI:
return {"status": "ok"}
@app.get("/", include_in_schema=False)
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html", headers=NOCACHE)
# Admin-only: Telegram login page. Registered BEFORE the static catch-all
# so the static mount can't accidentally serve it to non-admin visitors.
@app.get(
"/admin.html",
include_in_schema=False,
dependencies=[Depends(require_admin_network)],
)
async def admin_page() -> FileResponse:
return FileResponse(STATIC_DIR / "admin.html", headers=NOCACHE)
@app.get(
"/auth.html",
include_in_schema=False,
dependencies=[Depends(require_admin)],
)
async def auth_page() -> FileResponse:
return FileResponse(STATIC_DIR / "auth.html", headers=NOCACHE)
async def index() -> dict[str, str]:
return {"service": "monitoring-tg", "ui": "portal"}
# Admin-only: OpenAPI surface. Custom routes so we can wrap them in
# `require_admin`; the auto-generated ones from FastAPI bypass it.
@@ -139,7 +88,7 @@ def create_app() -> FastAPI:
include_in_schema=False,
dependencies=[Depends(require_admin)],
)
async def docs() -> FileResponse:
async def docs():
return get_swagger_ui_html(
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
title=app.title + " — docs",
@@ -150,42 +99,16 @@ def create_app() -> FastAPI:
include_in_schema=False,
dependencies=[Depends(require_admin)],
)
async def redoc() -> FileResponse:
async def redoc():
return get_redoc_html(
openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json",
title=app.title + " — redoc",
)
# IMPORTANT: register /static and /media mounts BEFORE the dynamic
# vertical/section routes. Starlette matches routes in registration order,
# and a generic /{v}/{s}/{page} pattern would otherwise eat /static/*.
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
media_dir = Path(settings.media_dir)
media_dir.mkdir(parents=True, exist_ok=True)
# /media is fine to cache — file names are content-stable.
app.mount("/media", StaticFiles(directory=media_dir), name="media")
# Section-templated dynamic routes, explicit per vertical so /static/*,
# /api/*, /media/* (and any future top-level path) can't be captured.
@app.get("/real-estate/{section}/", include_in_schema=False)
async def re_section_root(section: str) -> FileResponse:
return _serve_section_template("real-estate", "index.html")
@app.get("/real-estate/{section}/{page}", include_in_schema=False)
async def re_section_page(section: str, page: str) -> FileResponse:
return _serve_section_template("real-estate", page)
@app.get("/hr/{section}/", include_in_schema=False)
async def hr_section_root(section: str) -> FileResponse:
return _serve_section_template("hr", "index.html")
@app.get("/hr/{section}/{page}", include_in_schema=False)
async def hr_section_page(section: str, page: str) -> FileResponse:
return _serve_section_template("hr", page)
# Catch-all for top-level static pages (chooser, css, etc.). auth.html is
# already handled above, so the static catch-all can't bypass the guard.
app.mount("/", NoCacheStaticFiles(directory=STATIC_DIR, html=True), name="pages")
return app