"""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. Inline scripts/styles stay enabled for the Alpine/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'; " "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)