CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
The design surface is for the projects an install tracks. /design read the running app's own stylesheet — names out of a bundled theme.css, values out of getComputedStyle(document.documentElement) — so it could only ever describe the instance serving the page. Scribe is one project among the projects Scribe tracks; it gets no view hardcoded into every install. The mechanism that makes this a tool rather than a mirror already existed and already covers Scribe: scripts/check_design_tokens.py runs in CI against a sheet path it knows nothing about, using check_code_against_tokens — the same engine behind check_snippets_against_system. /design was redundant even here. Removed: DesignView, DesignTabs (nothing left to tab between), api/design.ts, routes/design.py and its blueprint, the /design route, ui_design_system() and its setting, and the Settings picker that designated "this app's UI". utils/designTokens.ts and utils/designDrift.ts go with it — between them they were the browser-reading half. What survives is utils/designValues.ts, which works on a record rather than a document: valueForMode, modesPresent, and resolveDeclared. resolveDeclared gained real isolation in the move. Custom properties inherit and `all: initial` does not reset them, so a probe sitting in this page would resolve any reference a record leaves undeclared against the SURROUNDING app's tokens — previewing another project's system would quietly borrow this one's palette wherever that system was incomplete, and a token already reported under unknown_refs would render as though it were fine. Undeclared references are now blanked on the probe first, so they resolve to nothing, which is what the record says they are. Migration 0075 absorbs ui_design_system_id alongside design_rulebook_id rather than an 0076 undoing it: 0075 has not run anywhere, since dev is unmerged and deploys come from main. Both keys named a design source for the running install, and a project already carries its own pointer. This retires the agreement panel shipped yesterday. It asked whether the sheet was actually loaded and applied — the one question a record cannot answer about itself — but only ever about the app you are already inside. Nothing replaces it; recorded in #2430 rather than quietly dropped. Step 1 of milestone #274. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
301 lines
12 KiB
Python
301 lines
12 KiB
Python
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.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.design_systems import design_systems_bp
|
|
from scribe.routes.trash import trash_bp
|
|
from scribe.routes.dashboard import dashboard_bp
|
|
from scribe.routes.systems import systems_bp
|
|
from scribe.routes.snippets import snippets_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(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(design_systems_bp)
|
|
app.register_blueprint(trash_bp)
|
|
app.register_blueprint(dashboard_bp)
|
|
app.register_blueprint(systems_bp)
|
|
app.register_blueprint(snippets_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)
|
|
# 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 scribe.services.snippets import backfill_snippet_data
|
|
await backfill_snippet_data()
|
|
except Exception:
|
|
logger.warning("Snippet data backfill failed", exc_info=True)
|
|
|
|
asyncio.create_task(_delayed_backfill())
|
|
|
|
# Recurrence scheduler (recurring-task spawn every 15m)
|
|
from scribe.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 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())
|
|
|
|
# DB maintenance scheduler (daily targeted VACUUM ANALYZE, default 04:00 UTC)
|
|
from scribe.services.db_maintenance_scheduler import (
|
|
get_maintenance_hour,
|
|
start_db_maintenance_scheduler,
|
|
)
|
|
start_db_maintenance_scheduler(
|
|
asyncio.get_running_loop(), await get_maintenance_hour()
|
|
)
|
|
|
|
# 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.recurrence_scheduler import stop_recurrence_scheduler
|
|
stop_recurrence_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.db_maintenance_scheduler import (
|
|
stop_db_maintenance_scheduler,
|
|
)
|
|
stop_db_maintenance_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
|