diff --git a/app/main.py b/app/main.py index fbc2f03..ded601c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import html import logging from contextlib import asynccontextmanager from datetime import date @@ -5,14 +6,14 @@ 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 -from fastapi.responses import HTMLResponse, JSONResponse +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 PAGE_TEMPLATE +from .templates import CSS, PAGE_TEMPLATE logging.basicConfig( level=logging.INFO, @@ -64,6 +65,42 @@ async def lifespan(app: FastAPI): 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(): @@ -80,7 +117,7 @@ def index(): status_code=503, ) items = "\n".join( - f'
  • {j}
  • ' for j in batch["jokes"] + f'
  • {html.escape(j)}
  • ' for j in batch["jokes"] ) stale_note = ( '

    ⚠️ Today\'s jokes are still being prepared — ' @@ -92,8 +129,8 @@ def index(): day=batch["day"], stale_note=stale_note, jokes=items, - article_title=batch["article_title"], - article_url=batch["article_url"], + article_title=html.escape(batch["article_title"]), + article_url=html.escape(batch["article_url"], quote=True), ) diff --git a/app/templates.py b/app/templates.py index aa10d77..67c6e6a 100644 --- a/app/templates.py +++ b/app/templates.py @@ -1,40 +1,42 @@ +CSS = """ +:root { color-scheme: light dark; } +body { + font-family: Georgia, 'Times New Roman', serif; + max-width: 720px; margin: 0 auto; padding: 2rem 1rem; + background: #fdfcf8; color: #222; +} +@media (prefers-color-scheme: dark) { + body { background: #1a1a1e; color: #e8e6e1; } + a { color: #8ab4f8; } + .joke { background: #26262c; color: #eceae4; border-color: #3a3a42; } + header h1 { color: #f0ede6; } +} +header h1 { font-size: 2rem; margin-bottom: .25rem; } +.date { color: #777; margin-top: 0; } +.stale { + background: #fff3cd; color: #665100; padding: .5rem .75rem; + border-radius: 6px; font-size: .9rem; +} +@media (prefers-color-scheme: dark) { + .stale { background: #4a3c0a; color: #ffd970; } +} +ul.jokes { list-style: none; padding: 0; } +li.joke { + background: #fff; color: #222; border: 1px solid #e2ddd2; border-radius: 10px; + padding: 1rem 1.25rem; margin: .9rem 0; font-size: 1.15rem; + line-height: 1.5; box-shadow: 0 1px 3px rgba(0,0,0,.06); +} +li.joke::before { content: "\\1F604 "; } +footer { margin-top: 2rem; font-size: .9rem; color: #888; } +""" + PAGE_TEMPLATE = """ Wiki Jokes — {day} - +

    @@ -47,7 +49,7 @@ PAGE_TEMPLATE = """