refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_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(trash_bp)
|
||||
app.register_blueprint(dashboard_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
|
||||
Reference in New Issue
Block a user