- Middleware sets CSP (default-src 'none'; style-src 'self'), HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy on every response - Inline <style> moved to /styles.css so CSP needs no unsafe-inline - /favicon.ico returns a 1x1 GIF (kills 404 noise) - Escape LLM jokes, article title, and URL before HTML rendering (XSS fix)
149 lines
4.2 KiB
Python
149 lines
4.2 KiB
Python
import html
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import date
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from . import db
|
|
from .config import settings
|
|
from .generator import generation_exhausted, run_generation, today_local
|
|
from .templates import CSS, PAGE_TEMPLATE
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
log = logging.getLogger("jokes.app")
|
|
|
|
scheduler = AsyncIOScheduler(timezone=ZoneInfo(settings.timezone))
|
|
|
|
|
|
async def tick() -> None:
|
|
"""Runs every RETRY_MINUTES: generate today's jokes if missing,
|
|
unless we've exhausted retries for today."""
|
|
day = today_local()
|
|
if db.get_day(day) is not None:
|
|
return
|
|
if generation_exhausted(day):
|
|
return
|
|
run_generation()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
db.init_db()
|
|
# Cold start: generate immediately if today's jokes are missing.
|
|
if db.get_day(today_local()) is None:
|
|
log.info("Cold start: generating today's jokes now")
|
|
run_generation()
|
|
# Daily schedule.
|
|
scheduler.add_job(
|
|
tick,
|
|
CronTrigger(
|
|
hour=settings.generate_hour,
|
|
minute=settings.generate_minute,
|
|
timezone=ZoneInfo(settings.timezone),
|
|
),
|
|
id="daily-generation",
|
|
)
|
|
# Retry loop (no-ops when today's jokes exist or retries are exhausted).
|
|
scheduler.add_job(
|
|
tick,
|
|
IntervalTrigger(minutes=settings.retry_minutes),
|
|
id="retry-generation",
|
|
)
|
|
scheduler.start()
|
|
yield
|
|
scheduler.shutdown(wait=False)
|
|
|
|
|
|
app = FastAPI(title="Wiki Jokes", lifespan=lifespan)
|
|
|
|
SECURITY_HEADERS = {
|
|
"Content-Security-Policy": (
|
|
"default-src 'none'; style-src 'self'; img-src 'self' data:; "
|
|
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"Referrer-Policy": "no-referrer",
|
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
}
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
for key, value in SECURITY_HEADERS.items():
|
|
response.headers[key] = value
|
|
return response
|
|
|
|
|
|
@app.get("/styles.css")
|
|
def styles():
|
|
return Response(content=CSS, media_type="text/css")
|
|
|
|
|
|
@app.get("/favicon.ico")
|
|
def favicon():
|
|
# Tiny 1x1 transparent GIF; stops 404 noise and scanner probing.
|
|
import base64
|
|
|
|
return Response(
|
|
content=base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
|
|
media_type="image/gif",
|
|
)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index():
|
|
today = today_local()
|
|
batch = db.get_day(today)
|
|
stale = False
|
|
if batch is None:
|
|
batch = db.get_latest()
|
|
stale = True
|
|
if batch is None:
|
|
return HTMLResponse(
|
|
"<h1>Wiki Jokes</h1><p>No jokes yet — the daily generator is "
|
|
"working on it. Check back soon!</p>",
|
|
status_code=503,
|
|
)
|
|
items = "\n".join(
|
|
f' <li class="joke">{html.escape(j)}</li>' for j in batch["jokes"]
|
|
)
|
|
stale_note = (
|
|
'<p class="stale">⚠️ Today\'s jokes are still being prepared — '
|
|
"showing the latest available batch.</p>"
|
|
if stale
|
|
else ""
|
|
)
|
|
return PAGE_TEMPLATE.format(
|
|
day=batch["day"],
|
|
stale_note=stale_note,
|
|
jokes=items,
|
|
article_title=html.escape(batch["article_title"]),
|
|
article_url=html.escape(batch["article_url"], quote=True),
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
today = today_local()
|
|
has_today = db.get_day(today) is not None
|
|
return JSONResponse(
|
|
{
|
|
"status": "ok",
|
|
"today": today.isoformat(),
|
|
"has_todays_jokes": has_today,
|
|
"retries_exhausted": generation_exhausted(today),
|
|
}
|
|
)
|