import os import logging from flask import Flask, jsonify from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix from backend.config import Config from backend.database.db_manager import init_user_db, health_check from backend.api import ( auth_bp, books_bp, payment_bp, translate_bp, epub_bp, developer_bp, user_features_bp, sects_bp ) from backend.api.monitoring import monitoring_bp logger = logging.getLogger("backend") def create_app(): """Flask Application Factory.""" root_dir = Config.ROOT_DIR app = Flask( __name__, static_folder=os.path.join(root_dir, "frontend-web", "dist"), static_url_path="", ) # Wrap with ProxyFix to handle reverse proxy headers app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) # ── Configuration ──────────────────────────────────────────────── app.secret_key = Config.SECRET_KEY app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 # 256MB # ── CORS ───────────────────────────────────────────────────────── CORS(app, resources={ r"/*": { "origins": ["*"], "allow_headers": ["Content-Type", "Authorization", "X-VIP-Code", "X-VIP-Key"], "methods": ["GET", "POST", "OPTIONS", "DELETE", "PUT"], "max_age": 86400 } }) # ── Register Blueprints ─────────────────────────────────────────── app.register_blueprint(auth_bp) app.register_blueprint(books_bp) app.register_blueprint(payment_bp) app.register_blueprint(translate_bp) app.register_blueprint(epub_bp) app.register_blueprint(developer_bp) app.register_blueprint(user_features_bp) app.register_blueprint(sects_bp) app.register_blueprint(monitoring_bp) # ── Profiler & Latency Monitoring ───────────────────────────────── from backend.core.profiler import setup_profiler setup_profiler(app) # ── Frontend Route ─────────────────────────────────────────────── @app.route("/") def index(): try: return app.send_static_file("index.html") except Exception: return jsonify({"status": "healthy", "message": "Tienhiep API Backend is running."}) @app.errorhandler(404) def page_not_found(e): from flask import request path = request.path.lstrip("/") if path.startswith("api/") or path.startswith("assets/") or path.startswith("v1/") or path.startswith("health") or path.startswith("translate"): return jsonify({"error": "Not Found"}), 404 try: return app.send_static_file("index.html") except Exception: return jsonify({"error": "Not Found"}), 404 # ── Health Check ──────────────────────────────────────────────── @app.route("/health", methods=["GET"]) def health_endpoint(): status = health_check() http_code = 200 if status["status"] in ("healthy", "degraded") else 503 return jsonify(status), http_code # ── CORS & Security after-request headers ──────────────────────── @app.after_request def add_cors_headers(response): from flask import request origin = request.headers.get("Origin") if origin: response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Max-Age"] = "86400" # Cho phép Cloudflare Cache luôn lệnh thăm dò OPTIONS (24h) if request.method == "OPTIONS": response.headers["Cache-Control"] = "public, max-age=86400" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-VIP-Code, X-VIP-Key" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, DELETE, PUT" else: # Ngăn Cloudflare và Trình duyệt lưu Cache các nội dung API thật response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["X-Content-Type-Options"] = "nosniff" if request.path.startswith("/embed") or request.path.startswith("/iframe_proxy"): response.headers["Content-Security-Policy"] = "frame-ancestors *" response.headers.pop("X-Frame-Options", None) else: response.headers["X-Frame-Options"] = "SAMEORIGIN" # Sanitize raw database error leaks from reaching public clients if response.content_type == "application/json": try: data = response.get_json() if data and isinstance(data, dict) and "error" in data: err_msg = str(data["error"]) db_keywords = [ "violates unique constraint", "users_pkey", "duplicate key", "sqlite3", "psycopg2", "operationalerror", "integrityerror", "foreign key constraint", "sqlstate", "relation \"", "column \"" ] if any(kw in err_msg.lower() for kw in db_keywords): logger.error(f"[Security Shield] Intercepted raw DB error leak: {err_msg}") from flask import json sanitized_data = {"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."} response.set_data(json.dumps(sanitized_data)) except Exception as e: logger.warning(f"Error in sanitize_database_errors: {e}") return response @app.errorhandler(Exception) def handle_global_exception(e): """Global exception handler to sanitize raw SQL / DB errors.""" from werkzeug.exceptions import HTTPException if isinstance(e, HTTPException): return jsonify({"error": e.description}), e.code logger.exception(f"Unhandled system error: {e}") return jsonify({"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}), 500 # ── Initialize DB ──────────────────────────────────────────────── try: init_user_db() logger.info("✔ Database initialized on app startup.") except Exception as e: logger.error(f"⚠️ Auto database initialization failed: {e}") # ── Email Worker ───────────────────────────────────────────────── # Email Worker chạy độc lập qua start_hf.sh (python3 backend/workers/email_worker.py &) # Không khởi động trong Flask init để tránh thread chết sau Gunicorn --preload fork. logger.info("📧 [Email Worker] Managed externally by start_hf.sh") # ── Pre-load translation engine ────────────────────────────────── try: logger.info("⏳ Pre-loading Vietphrase Engine...") from backend.services.translation import get_engine engine = get_engine() logger.info("⏳ Warming up translation modes...") for mode in ["fast", "advanced", "vietphrase", "hanviet", "advanced_hanviet"]: engine.translate("叶凡", mode=mode) logger.info("✅ Vietphrase Engine loaded & warmed up successfully.") except Exception as e: logger.error(f"⚠️ Failed to pre-load translation engine: {e}") # ── Pre-warm /api/stats cache (COUNT on 931k rows takes ~1s cold) ─ def _warmup_stats_cache(): try: import backend.database.db_manager as db_mod if db_mod.cached_stats is None: from backend.database.db_manager import get_db conn = get_db() total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0] cats = conn.execute( "SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''" ).fetchall() cat_counts = {} for row in cats: for c in row[0].split(","): c = c.strip() if c: cat_counts[c] = cat_counts.get(c, 0) + 1 top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20] db_mod.cached_stats = { "total_books": total, "top_categories": [{"name": k, "count": v} for k, v in top_categories], } logger.info(f"✅ Stats cache warmed up: {total:,} books indexed.") except Exception as e: logger.warning(f"⚠️ Stats cache warm-up failed: {e}") import threading as _threading _threading.Thread(target=_warmup_stats_cache, daemon=True, name="StatsCacheWarmup").start() # ── Watchdog Keepalive (HF Space anti-sleep) ───────────────────── if not app.testing and os.environ.get("SPACE_HOST"): try: from backend.core.watchdog import start_watchdog space_url = f"https://{os.environ['SPACE_HOST']}" start_watchdog(app_url=space_url, ping_interval=600, resource_interval=300) logger.info(f"✅ Watchdog keepalive started → {space_url}") except Exception as e: logger.warning(f"⚠️ Watchdog could not start: {e}") return app