from __future__ import annotations import mimetypes import os import secrets from datetime import timedelta from quart import Quart, has_request_context, jsonify, request, send_from_directory from quart.sessions import SecureCookieSessionInterface from . import __version__ from .auth import bp as auth_bp from .config import Config from .db import session_scope from .graph import bp as graph_bp from .labels import bp as labels_bp from .notes import bp as notes_bp from .saved_filters import bp as saved_filters_bp from .settings import get_public_config, get_setting, load_or_create_secret_key from .settings_api import bp as settings_bp from .sync import bp as sync_bp, protocol_advertisement STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") # `.webmanifest` isn't in every base image's mime map; register it so the PWA # manifest is served as application/manifest+json instead of octet-stream. mimetypes.add_type("application/manifest+json", ".webmanifest") class _AutoSecureSessionInterface(SecureCookieSessionInterface): """Mark the session cookie `Secure` whenever the request arrived over HTTPS — directly, or via a TLS-terminating reverse proxy that sets X-Forwarded-Proto. Auto-detecting per request (rather than a fixed SESSION_COOKIE_SECURE flag) hardens the cookie on HTTPS deployments without breaking a plain-HTTP install on a trusted LAN, where a hard-forced Secure flag would stop the browser from ever sending the cookie back — i.e. silently break login. No configuration required. """ def get_cookie_secure(self, app: Quart) -> bool: if not has_request_context(): return False forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower() return forwarded == "https" or request.is_secure def create_app() -> Quart: # static_folder=None: the SPA catch-all below owns static serving. app = Quart(__name__, static_folder=None) # Ephemeral/env secret so the app (and DB-free unit tests) construct without a # database. before_serving swaps in the real, DB-persisted key before serving. app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48) app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__) app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # Auto-mark the session cookie Secure on HTTPS requests (see the interface above). app.session_interface = _AutoSecureSessionInterface() app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Hard request-body ceiling (any-file attachments, import zips, sync push). The # per-file attachment limit is the DB-backed `max_attachment_mb` setting, enforced # in the upload handler; this must stay >= the largest value that allows. app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 app.register_blueprint(auth_bp) app.register_blueprint(notes_bp) app.register_blueprint(labels_bp) app.register_blueprint(graph_bp) app.register_blueprint(settings_bp) app.register_blueprint(sync_bp) app.register_blueprint(saved_filters_bp) @app.before_serving async def _bootstrap() -> None: # Load (or generate + persist) the real signing secret and the live session # lifetime from the DB, before any request is served. async with session_scope() as db: app.secret_key = await load_or_create_secret_key(db) try: days = int(await get_setting(db, "session_ttl_days")) app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days) except (ValueError, TypeError, KeyError): pass @app.get("/api/health") async def health(): return jsonify({"status": "ok", "version": app.config["APP_VERSION"]}) @app.get("/api/config") async def public_config(): # Public: the login/register screen reads site name + whether signups are open. async with session_scope() as db: data = await get_public_config(db) data["version"] = app.config["APP_VERSION"] # The sync-protocol handshake (M10.6). A native client reads this BEFORE # linking — while it still has no token and possibly no account — to decide # whether it can talk to this server, and which optional features to offer. data.update(protocol_advertisement()) return jsonify(data) @app.get("/", defaults={"path": ""}) @app.get("/") async def spa(path: str): if path.startswith("api/"): return jsonify({"error": "not found"}), 404 candidate = os.path.join(STATIC_DIR, path) if path and os.path.isfile(candidate): return await send_from_directory(STATIC_DIR, path) index = os.path.join(STATIC_DIR, "index.html") if os.path.isfile(index): return await send_from_directory(STATIC_DIR, "index.html") return jsonify({"error": "frontend not built"}), 404 return app