Files
FabledGrimoire/src/grimoire/app.py
T
bvandeusenandClaude Opus 5.5 753a6ccb31
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m47s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
feat(6866): the proxy hop count is an admin setting, and the server shows what each count reads
An admin can save how many reverse proxies stand in front of Grimoire.
A saved count wins over TRUSTED_PROXY_HOPS, which stays the starting
value. Each worker loads it at startup (under a deadline) and re-reads
it every 30s; the worker that saves applies it at once.

GET/PUT /api/admin/proxy returns the count, where it came from, and a
readout of the admin's own request: the socket peer, the forwarding
chains, what counts 0-3 would read, and the count that reads
Cloudflare's visitor when Cloudflare's headers are present. Saves are
audit-logged. A "too many attempts" reply now says where to look when
everyone is being blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:55:42 -05:00

431 lines
19 KiB
Python

import logging
import os
import time
import traceback as tb_module
from pathlib import Path
from quart import Quart, g, jsonify, make_response, request, send_from_directory
from grimoire.config import SECRET_KEY_FILENAME, Config
from grimoire.proxy import PerRequestSecureSession, apply_https_headers
from grimoire.routes.admin import admin_bp
from grimoire.routes.api import api
from grimoire.routes.auth import auth_bp
from grimoire.routes.export import export_bp
from grimoire.routes.notes import notes_bp
from grimoire.routes.milestones import milestones_bp
from grimoire.routes.task_logs import task_logs_bp
from grimoire.routes.projects import projects_bp
from grimoire.routes.retrieval import retrieval_bp
from grimoire.routes.settings import settings_bp
from grimoire.routes.tasks import tasks_bp
from grimoire.routes.groups import groups_bp
from grimoire.routes.shares import shares_bp
from grimoire.routes.in_app_notifications import notifications_bp
from grimoire.routes.users import users_bp
from grimoire.routes.api_keys import api_keys_bp
from grimoire.routes.search import search_bp
from grimoire.routes.knowledge import knowledge_bp
from grimoire.routes.rulebooks import rulebooks_bp
from grimoire.routes.plugin import plugin_bp
from grimoire.routes.design_systems import design_systems_bp
from grimoire.routes.trash import trash_bp
from grimoire.routes.dashboard import dashboard_bp
from grimoire.routes.systems import systems_bp
from grimoire.routes.canonical_systems import canonical_systems_bp
from grimoire.routes.platforms import platforms_bp
from grimoire.routes.family import family_bp
from grimoire.routes.lessons import lessons_bp
from grimoire.routes.snippets import snippets_bp
from grimoire.routes.webhooks import webhooks_bp
from grimoire.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"
# Secure is decided per request, from the trusted proxy's X-Forwarded-Proto.
app.session_interface = PerRequestSecureSession()
if os.environ.get("SECURE_COOKIES"):
logger.warning(
"SECURE_COOKIES is not used: the session cookie is Secure on every request "
"the trusted proxy reports as HTTPS. Remove it from the environment."
)
if Config.SECRET_KEY_SOURCE == "ephemeral":
logger.warning(
"No SECRET_KEY is set and %s does not exist, so this process is using a "
"temporary key. Sessions and saved credentials will not survive a restart.",
Config.DATA_DIR,
)
elif Config.SECRET_KEY_SOURCE == "data" and not os.path.ismount(Config.DATA_DIR):
logger.warning(
"%s is not a mounted volume. The instance's key (%s) and the model cache "
"are lost when the container is recreated; mount a volume there.",
Config.DATA_DIR, SECRET_KEY_FILENAME,
)
if os.environ.get("TRUST_PROXY_HEADERS") and not os.environ.get("TRUSTED_PROXY_HOPS"):
logger.warning(
"TRUST_PROXY_HEADERS is not used (the starting proxy hop count is %d). "
"Remove it; set the count in Settings → Config, or TRUSTED_PROXY_HOPS "
"to change the starting value.",
Config.TRUSTED_PROXY_HOPS,
)
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(retrieval_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(search_bp)
app.register_blueprint(knowledge_bp)
app.register_blueprint(lessons_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
app.register_blueprint(canonical_systems_bp)
app.register_blueprint(platforms_bp)
app.register_blueprint(family_bp)
app.register_blueprint(snippets_bp)
app.register_blueprint(webhooks_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 grimoire.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)
apply_https_headers(response)
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'; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"font-src 'self' data:; "
"object-src 'none'; "
"base-uri 'self'; worker-src 'self' blob:;"
)
return response
@app.before_serving
async def startup():
import asyncio
# Set on the last line of this hook; the deferred backfill below waits
# on it. An Event rather than a bare bool so the waiter is woken
# instead of polling, and declared here — inside the hook, where
# `asyncio` is in scope and a running loop exists — rather than in
# `create_app`, which runs before either is true.
_startup_finished = asyncio.Event()
from grimoire.services.auth import start_auth_token_retention_loop
from grimoire.services.embeddings import (
backfill_milestone_embeddings, backfill_note_embeddings,
backfill_rule_embeddings, backfill_system_embeddings,
)
from grimoire.services.logging import start_log_retention_loop
from grimoire.services.notifications import start_notification_loop
start_log_retention_loop()
start_notification_loop()
start_auth_token_retention_loop()
# The proxy hop count saved in Settings decides every request's client
# address, so it is loaded before serving, under a deadline (a failed
# or slow read leaves the environment's count in force), and re-read
# on an interval so a save in another worker reaches this one.
from grimoire.services import proxy_settings
await proxy_settings.load_at_startup()
proxy_settings.start_refresh_loop()
# Write down any shipped default that moved in this release.
#
# INLINE AND AWAITED, deliberately, against the instinct the block
# below argues for. The startup hazard is CONCURRENCY — a background
# task created here racing this hook for the same pool. Running
# sequentially creates no such contention, and this is a dozen
# single-row reads on an index against a table with a handful of rows.
#
# It has to finish before serving because the thing it protects is a
# telemetry read: `band_hugs_floor` compares a band against a floor,
# and across a release that changed the floor those describe different
# regimes. A readout served before the change was recorded is the very
# answer this exists to stop giving. Failure is logged and swallowed —
# provenance is worth a lot and never worth refusing to boot.
try:
from grimoire.services.retrieval_tuning import record_release_defaults
moved = await record_release_defaults()
for row in moved:
if not row["baseline"]:
app.logger.info(
"release moved %s's shipped %s: %s -> %s",
row["surface"], row["dial"],
row["old_value"], row["new_value"],
)
except Exception:
app.logger.warning("could not record release defaults", exc_info=True)
# Backfill embeddings for any notes that don't have one. Runs in the
# background so it never blocks the server from accepting requests —
# and not until the rest of this hook has finished.
#
# "Background" is true of REQUESTS and false of STARTUP. A task
# created here begins immediately, while `before_serving` is still
# running, so its passes would compete with the startup hook's own
# database reads for the same connection pool. That is survivable on a
# healthy disk and fatal on a sick one: Hypercorn kills a worker whose
# lifespan hook overruns, and nothing retries it.
#
# The wait is on the SERVING FLAG rather than a sleep, because a sleep
# would be a guess about how long the rest of the hook takes and would
# be wrong in exactly the conditions that matter.
async def _delayed_backfill() -> None:
await _startup_finished.wait()
try:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
# A rule with no vector is unfindable by meaning; this pass fills
# any that are missing.
try:
await backfill_rule_embeddings()
except Exception:
logger.warning("Rule embedding backfill failed", exc_info=True)
# Likewise a milestone: a plan with no vector is findable only
# after this pass.
try:
await backfill_milestone_embeddings()
except Exception:
logger.warning("Milestone embedding backfill failed", exc_info=True)
# A System charter with no vector is unfindable — the System could
# narrow a search and never be the answer to one.
try:
await backfill_system_embeddings()
except Exception:
logger.warning("System embedding backfill failed", exc_info=True)
# Snippets written before migration 0070 have no `notes.data` mirror,
# and the location reverse lookup queries that column — an unfilled
# row would read as "no snippet here" rather than as a gap. Separate
# try block so neither backfill can skip the other.
try:
from grimoire.services.snippets import backfill_snippet_data
await backfill_snippet_data()
except Exception:
logger.warning("Snippet data backfill failed", exc_info=True)
# Created here but gated on the flag released at the END of this hook,
# so the task exists (nothing can forget to start it) while none of its
# work overlaps startup's own.
asyncio.create_task(_delayed_backfill())
# RELEASED IN A `finally`, never after the work.
# The waiter above has no deadline of its own, and the way to make an
# undeadlined wait safe is to make the release unmissable: if anything
# below raises, the flag is still set and the task ends instead of
# living on as a coroutine nobody will ever wake.
try:
# Recurrence scheduler (recurring-task spawn every 15m)
from grimoire.services.recurrence_scheduler import start_recurrence_scheduler
start_recurrence_scheduler(asyncio.get_running_loop())
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from grimoire.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 grimoire.services.trash_scheduler import start_trash_scheduler
start_trash_scheduler(asyncio.get_running_loop())
# DB maintenance scheduler (daily targeted VACUUM ANALYZE, default
# 04:00 UTC). `get_maintenance_hour` is the FIRST database read in
# this hook and is bounded for that reason — see the long
# comment above `_STARTUP_READ_TIMEOUT`. Anything added here that
# touches the database needs the same treatment: a lifespan hook
# that does not return is a worker that never serves.
from grimoire.services.db_maintenance_scheduler import (
get_maintenance_hour,
start_db_maintenance_scheduler,
)
start_db_maintenance_scheduler(
asyncio.get_running_loop(), await get_maintenance_hour()
)
# Scheduled backups. An hourly tick that reads its config when it
# fires, so starting it reads nothing here.
from grimoire.services.backup_job import start_backup_scheduler
start_backup_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 grimoire.services.diagnostics import start_diagnostics
start_diagnostics(asyncio.get_running_loop())
finally:
# STARTUP IS OVER — release the backfill. Everything above
# is work the server needs done before it serves; everything the
# backfill does is work that can wait for a server already up.
logger.info("Startup complete; releasing deferred backfill")
_startup_finished.set()
@app.after_serving
async def shutdown():
from grimoire.services.recurrence_scheduler import stop_recurrence_scheduler
stop_recurrence_scheduler()
from grimoire.services.version_pinning_scheduler import (
stop_version_pinning_scheduler,
)
stop_version_pinning_scheduler()
from grimoire.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler()
from grimoire.services.db_maintenance_scheduler import (
stop_db_maintenance_scheduler,
)
stop_db_maintenance_scheduler()
from grimoire.services.backup_job import stop_backup_scheduler
stop_backup_scheduler()
from grimoire.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 grimoire.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