Spaces:
Running
Running
| 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 βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def index(): | |
| try: | |
| return app.send_static_file("index.html") | |
| except Exception: | |
| return jsonify({"status": "healthy", "message": "Tienhiep API Backend is running."}) | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββ | |
| 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 | |
| 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 | |