"""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_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)") 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 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; the Alpine.js/Tailwind CDNs need the CDN hosts + inline script/style for this demo); * ``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' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; " "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())) 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) ─────────────────────────────────────────── 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") # ── 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)