import logging import time import traceback as tb_module from pathlib import Path from quart import Quart, g, jsonify, make_response, request, send_from_directory from scribe.config import Config from scribe.routes.admin import admin_bp from scribe.routes.api import api from scribe.routes.auth import auth_bp from scribe.routes.export import export_bp from scribe.routes.notes import notes_bp from scribe.routes.milestones import milestones_bp from scribe.routes.task_logs import task_logs_bp from scribe.routes.projects import projects_bp from scribe.routes.settings import settings_bp from scribe.routes.tasks import tasks_bp from scribe.routes.groups import groups_bp from scribe.routes.shares import shares_bp from scribe.routes.in_app_notifications import notifications_bp from scribe.routes.users import users_bp from scribe.routes.api_keys import api_keys_bp from scribe.routes.events import events_bp from scribe.routes.search import search_bp from scribe.routes.profile import profile_bp from scribe.routes.knowledge import knowledge_bp from scribe.routes.rulebooks import rulebooks_bp from scribe.routes.plugin import plugin_bp from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp from scribe.routes.systems import systems_bp from scribe.mcp import mount_mcp STATIC_DIR = Path(__file__).parent / "static" logger = logging.getLogger(__name__) def create_app() -> Quart: # Configure logging logging.basicConfig( format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO), ) # Validate config early so misconfigurations surface immediately with clear messages try: Config.validate() except ValueError as exc: logging.getLogger(__name__).error("Invalid configuration:\n%s", exc) raise app = Quart(__name__, static_folder=None) app.secret_key = Config.SECRET_KEY app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES if Config.SECRET_KEY == "dev-secret-change-me": logger.warning( "SECRET_KEY is set to the default value — session cookies are insecure. " "Set SECRET_KEY or SECRET_KEY_FILE for production use." ) if not Config.TRUST_PROXY_HEADERS: logger.warning( "TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy " "(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses " "real client IPs rather than the proxy IP." ) app.register_blueprint(admin_bp) app.register_blueprint(api) app.register_blueprint(auth_bp) app.register_blueprint(export_bp) app.register_blueprint(milestones_bp) app.register_blueprint(notes_bp) app.register_blueprint(projects_bp) app.register_blueprint(settings_bp) app.register_blueprint(task_logs_bp) app.register_blueprint(tasks_bp) app.register_blueprint(groups_bp) app.register_blueprint(shares_bp) app.register_blueprint(notifications_bp) app.register_blueprint(users_bp) app.register_blueprint(api_keys_bp) app.register_blueprint(events_bp) app.register_blueprint(search_bp) app.register_blueprint(profile_bp) app.register_blueprint(knowledge_bp) app.register_blueprint(rulebooks_bp) app.register_blueprint(plugin_bp) app.register_blueprint(trash_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(systems_bp) @app.before_request async def before_request(): g.request_start = time.monotonic() @app.after_request async def after_request(response): duration = time.monotonic() - getattr(g, "request_start", time.monotonic()) duration_ms = round(duration * 1000, 1) # Downgrade noisy high-frequency / static paths to DEBUG _quiet = ( request.path in {"/api/health", "/api/chat/status"} or request.path.startswith(("/static/", "/assets/", "/sw.js", "/manifest.json")) ) log_fn = logger.debug if _quiet else logger.info log_fn( "%s %s %s %.1fms", request.method, request.path, response.status_code, duration_ms, ) # Log usage for API requests (skip logs endpoint to avoid recursion) if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"): try: from scribe.services.logging import log_usage user = getattr(g, "user", None) await log_usage( user_id=user.id if user else None, username=user.username if user else None, endpoint=request.path, method=request.method, status_code=response.status_code, duration_ms=duration_ms, ) except Exception: logger.debug("Failed to log usage", exc_info=True) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; " "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "img-src 'self' data: blob:; " "connect-src 'self'; " "font-src 'self' data: https://fonts.gstatic.com; " "object-src 'none'; " "base-uri 'self'; worker-src 'self' blob:;" ) return response @app.before_serving async def startup(): import asyncio from scribe.services.auth import start_auth_token_retention_loop from scribe.services.embeddings import backfill_note_embeddings from scribe.services.logging import start_log_retention_loop from scribe.services.notifications import start_notification_loop start_log_retention_loop() start_notification_loop() start_auth_token_retention_loop() # Backfill embeddings for any notes that don't have one. Runs in the # background so it never blocks the server from accepting requests. async def _delayed_backfill() -> None: try: await backfill_note_embeddings() except Exception: logger.warning("Embedding backfill failed", exc_info=True) asyncio.create_task(_delayed_backfill()) # Event scheduler (reminders + CalDAV pull sync) from scribe.services.event_scheduler import start_event_scheduler start_event_scheduler(asyncio.get_running_loop()) # Version-pinning scheduler (daily auto-pin scan at 03:00 UTC) from scribe.services.version_pinning_scheduler import ( start_version_pinning_scheduler, ) start_version_pinning_scheduler(asyncio.get_running_loop()) # Trash retention scheduler (daily expired-trash purge at 03:30 UTC) from scribe.services.trash_scheduler import start_trash_scheduler start_trash_scheduler(asyncio.get_running_loop()) # Diagnostic instrumentation — heartbeat, signal handlers, asyncio # exception hook. Cheap (~1 log line/min), high diagnostic value when # the app crashes mysteriously. See services/diagnostics.py. from scribe.services.diagnostics import start_diagnostics start_diagnostics(asyncio.get_running_loop()) @app.after_serving async def shutdown(): from scribe.services.event_scheduler import stop_event_scheduler stop_event_scheduler() from scribe.services.version_pinning_scheduler import ( stop_version_pinning_scheduler, ) stop_version_pinning_scheduler() from scribe.services.trash_scheduler import stop_trash_scheduler stop_trash_scheduler() from scribe.services.diagnostics import stop_diagnostics stop_diagnostics() @app.route("/") async def serve_index(): resp = await make_response( await send_from_directory(STATIC_DIR, "index.html") ) resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return resp # File extensions that belong to the SPA or its assets. # Anything else with an extension is not a valid app path and gets a hard 404. _SPA_EXTENSIONS = { "", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json", } @app.errorhandler(404) async def handle_404(error): # Return JSON 404 for API routes if request.path.startswith("/api/"): return jsonify({"error": "Not found"}), 404 # Try to serve a real static file first path = request.path.lstrip("/") file_path = STATIC_DIR / path if path and file_path.is_file(): return await send_from_directory(STATIC_DIR, path) # Reject paths with file extensions the app doesn't serve. # This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s # instead of serving them the Vue SPA with a misleading 200. from pathlib import PurePosixPath suffix = PurePosixPath(request.path).suffix.lower() if suffix not in _SPA_EXTENSIONS: return "", 404 # SPA fallback for clean client-side routes (/notes/123, /chat, etc.) resp = await make_response( await send_from_directory(STATIC_DIR, "index.html") ) resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return resp @app.errorhandler(500) async def handle_500(error): logger.exception("Internal server error on %s %s", request.method, request.path) try: from scribe.services.logging import log_error user = getattr(g, "user", None) await log_error( user_id=user.id if user else None, username=user.username if user else None, endpoint=request.path, method=request.method, error_type=type(error).__name__, error_message=str(error), traceback=tb_module.format_exc(), ) except Exception: logger.debug("Failed to log error", exc_info=True) if request.path.startswith("/api/"): return jsonify({"error": "Internal server error"}), 500 return "Internal Server Error", 500 mount_mcp(app) return app