Files
denya-onecare/app/main.py
root 40f1c0ecf6 fix(csp): add 'unsafe-eval' to script-src so Alpine.js initializes
Alpine 3.17.2's CDN build compiles every x-data/x-show/x-text expression
with new Function(), which the strict P0 CSP (script-src 'self'
'unsafe-inline') blocked. Every Alpine directive threw "Evaluating a
string as JavaScript violates ... 'unsafe-eval' is not an allowed
source", Alpine never initialized, and the loading overlay
(x-show="loading" in base.html) stayed visible forever on /login and
every Alpine-driven page.

Add 'unsafe-eval' to script-src (Alpine's documented CSP requirement for
its runtime); everything else in the header is unchanged. Regression test
asserts the /login CSP header carries 'unsafe-eval' inside script-src.

Verified live: headless chromium (playwright build 1243) shows zero
CSP/eval console errors after the fix, with Alpine applying
style="display:none" to the loading overlay; the pre-fix header produces
the Alpine Expression Error spam and leaves the overlay visible.
2026-09-09 15:45:52 +00:00

260 lines
11 KiB
Python

"""Denya OneCare — FastAPI application entry point."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, pages, tickets, whatsapp
from app.services.seed import (
normalize_legacy_user_emails,
normalize_legacy_user_roles,
seed_categories,
seed_units,
seed_users,
)
logger = logging.getLogger(__name__)
async def ensure_legacy_schema(conn) -> None:
"""Add columns/data changes from alembic migrations that legacy create_all databases lack."""
result = await conn.execute(text("PRAGMA table_info(categories)"))
columns = {row[1] for row in result}
if "show_in_form" not in columns:
await conn.execute(
text("ALTER TABLE categories ADD COLUMN show_in_form BOOLEAN NOT NULL DEFAULT 1")
)
logger.info("Added missing categories.show_in_form column (legacy database)")
result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='tickets'"))
if result.scalar():
result = await conn.execute(text("PRAGMA table_info(tickets)"))
ticket_columns = {row[1] for row in result}
if "phone" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)")
)
logger.info("Added missing tickets.phone column (legacy database)")
if "reported_at" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME")
)
await conn.execute(
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
)
logger.info("Added missing tickets.reported_at column (legacy database)")
# whatsapp_log predates the real Meta webhook (the model gained
# message_text/wa_message_id/ticket_number and dropped `command` in commit
# 4afdc36 with no migration), so legacy DBs still carry the old shape and
# every read/write through the ORM 500s (no such column: message_text).
result = await conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='whatsapp_log'")
)
if result.scalar():
result = await conn.execute(text("PRAGMA table_info(whatsapp_log)"))
log_columns = {row[1] for row in result}
if "message_text" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN message_text TEXT NOT NULL DEFAULT ''")
)
logger.info("Added missing whatsapp_log.message_text column (legacy database)")
if "wa_message_id" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN wa_message_id VARCHAR(100)")
)
logger.info("Added missing whatsapp_log.wa_message_id column (legacy database)")
if "ticket_number" not in log_columns:
await conn.execute(
text("ALTER TABLE whatsapp_log ADD COLUMN ticket_number VARCHAR(30)")
)
logger.info("Added missing whatsapp_log.ticket_number column (legacy database)")
if "command" in log_columns:
# Legacy rows stored the message body in `command`, which the model
# no longer defines (NOT NULL, no default): any ORM insert omitting
# it would violate NOT NULL. Preserve the old bodies in
# message_text, then drop the obsolete column to match the model.
await conn.execute(
text(
"UPDATE whatsapp_log SET message_text = command "
"WHERE (message_text IS NULL OR message_text = '') "
"AND command IS NOT NULL AND command != ''"
)
)
await conn.execute(text("ALTER TABLE whatsapp_log DROP COLUMN command"))
logger.info("Dropped obsolete whatsapp_log.command column (legacy database)")
result = await conn.execute(
text(
"UPDATE categories SET name = 'Missing Item' "
"WHERE type = 'cs' AND name = 'Lost Property' AND parent_id IS NULL "
"AND NOT EXISTS (SELECT 1 FROM categories c2 "
"WHERE c2.type = 'cs' AND c2.name = 'Missing Item' AND c2.parent_id IS NULL)"
)
)
if result.rowcount:
logger.info("Renamed legacy 'Lost Property' category to 'Missing Item'")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialise database and seed data on startup."""
logger.info("Starting Denya OneCare …")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_legacy_schema(conn)
async with async_session_factory() as session:
await seed_users(session)
# P0 role-model unification: converge legacy nickname roles (e.g.
# ``technician``/``cs``/``fm``) onto the canonical taxonomy at startup.
await normalize_legacy_user_roles(session)
await normalize_legacy_user_emails(session)
await session.commit()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit()
await seed_categories(session)
await session.commit()
yield
await engine.dispose()
logger.info("Denya OneCare stopped.")
app = FastAPI(
title=settings.APP_NAME,
version="0.1.0",
lifespan=lifespan,
)
# ── Security headers (P0 batch) ──────────────────────────────────────
class SecurityHeadersMiddleware:
"""Set hardening headers on every HTTP response.
* ``X-Frame-Options: DENY`` and ``X-Content-Type-Options: nosniff`` on
all responses;
* CSP on HTML pages (login + app pages). Alpine.js and Tailwind are
vendored same-origin (``/static/vendor/``), so no external hosts are
allowed and the page is fully self-contained — safe on LAN-only demo
clients. ``script-src`` keeps ``'unsafe-inline'`` for the inline
``tailwind.config``/``app()`` blocks and ``'unsafe-eval'`` because
Alpine 3.17.2's expression evaluator compiles ``x-data``/``x-show``/
``x-text`` etc. with ``new Function()`` — without ``'unsafe-eval'`` CSP
blocks every Alpine expression and the loading overlay never clears;
style-src keeps ``'unsafe-inline'`` for the Tailwind runtime;
* ``Cache-Control: no-cache`` on HTML pages so templates always
revalidate (the vendored assets themselves are cached immutably via
versioned filenames);
* ``Strict-Transport-Security`` only when TLS terminates (https scheme
or ``X-Forwarded-Proto: https`` from the reverse proxy).
"""
HSTS = "max-age=31536000; includeSubDomains"
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"object-src 'none'"
)
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
is_tls = scope.get("scheme") == "https"
for name, value in scope.get("headers") or []:
if name.lower() == b"x-forwarded-proto":
first = value.decode("latin-1").split(",", 1)[0].strip().lower()
if first == "https":
is_tls = True
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers") or [])
content_type = next(
(v for k, v in headers if k.lower() == b"content-type"), b""
)
if content_type.startswith(b"text/html"):
headers.append((b"content-security-policy", self.CSP.encode()))
# Templates must always revalidate: never serve a stale
# page that still points at old vendored filenames.
headers.append((b"cache-control", b"no-cache"))
headers.append((b"x-frame-options", b"DENY"))
headers.append((b"x-content-type-options", b"nosniff"))
if is_tls:
headers.append((b"strict-transport-security", self.HSTS.encode()))
message["headers"] = headers
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(SecurityHeadersMiddleware)
# ── CORS (HARDENING.md P0.2 — explicit origin allow-list, never "*") ──
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
if "*" in _origins or not _origins:
raise RuntimeError(
"CORS_ORIGINS must be an explicit comma-separated origin allow-list "
"(e.g. 'https://denya.sysloggh.net,http://localhost:8000'). "
"'*' with allow_credentials=True is invalid and unsafe. Refusing to start."
)
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Static files (uploads + vendored frontend assets) ────────────────
class ImmutableStaticFiles(StaticFiles):
"""StaticFiles that serves long-lived immutable cache headers.
Used for the vendored frontend libraries under ``app/static/vendor/``
(Alpine.js + Tailwind Play). Their URLs embed the version, so upgrading
later just bumps the filename and clients fetch the new artifact instead
of a stale immutable copy.
"""
def file_response(self, full_path, stat_result, scope, status_code=200):
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["cache-control"] = "public, max-age=31536000, immutable"
return response
uploads_dir = Path(settings.BASE_DIR / "uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Alpine.js/Tailwind are vendored same-origin so LAN-only demo clients render
# the login/dashboards with no external network (see app/templates/base.html).
static_dir = Path(__file__).resolve().parent / "static"
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", ImmutableStaticFiles(directory=str(static_dir)), name="static")
# ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(whatsapp.router)
app.include_router(tickets.router)
app.include_router(pages.router)