295 lines
16 KiB
Python
295 lines
16 KiB
Python
from fastapi import FastAPI, Request, HTTPException, Depends, status
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.exceptions import RequestValidationError
|
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
|
from jose.exceptions import JWTError
|
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
from slowapi.util import get_remote_address
|
|
from slowapi.errors import RateLimitExceeded
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import sys
|
|
import secrets
|
|
import os
|
|
import re
|
|
import logging
|
|
from .shared.config.settings import settings
|
|
from .shared.config.logging_config import setup_logging, get_logger
|
|
from .shared.config.database import engine, Base, get_db
|
|
from . import models
|
|
from sqlalchemy.orm import Session
|
|
logger = setup_logging()
|
|
logger.info(f'Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode')
|
|
from .shared.middleware.error_handler import validation_exception_handler, integrity_error_handler, jwt_error_handler, http_exception_handler, general_exception_handler
|
|
from .shared.middleware.request_id import RequestIDMiddleware
|
|
from .security.middleware.security import SecurityHeadersMiddleware
|
|
from .shared.middleware.timeout import TimeoutMiddleware
|
|
from .shared.middleware.cookie_consent import CookieConsentMiddleware
|
|
from .security.middleware.csrf import CSRFProtectionMiddleware
|
|
from .shared.middleware.request_size_limit import RequestSizeLimitMiddleware
|
|
from .security.middleware.admin_ip_whitelist import AdminIPWhitelistMiddleware
|
|
if settings.is_development:
|
|
logger.info('Creating database tables (development mode)')
|
|
Base.metadata.create_all(bind=engine)
|
|
else:
|
|
try:
|
|
from .content.models.cookie_policy import CookiePolicy
|
|
from .content.models.cookie_integration_config import CookieIntegrationConfig
|
|
from .content.models.page_content import PageContent
|
|
logger.info('Ensuring required tables exist')
|
|
CookiePolicy.__table__.create(bind=engine, checkfirst=True)
|
|
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
|
|
PageContent.__table__.create(bind=engine, checkfirst=True)
|
|
except Exception as e:
|
|
logger.error(f'Failed to ensure required tables exist: {e}')
|
|
from .auth.routes import auth_routes
|
|
from .content.routes import privacy_routes
|
|
app = FastAPI(title=settings.APP_NAME, description='Enterprise-grade Hotel Booking API', version=settings.APP_VERSION, docs_url='/api/docs' if not settings.is_production else None, redoc_url='/api/redoc' if not settings.is_production else None, openapi_url='/api/openapi.json' if not settings.is_production else None)
|
|
app.add_middleware(RequestIDMiddleware)
|
|
app.add_middleware(CookieConsentMiddleware)
|
|
if settings.REQUEST_TIMEOUT > 0:
|
|
app.add_middleware(TimeoutMiddleware)
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
app.add_middleware(RequestSizeLimitMiddleware, max_size=settings.MAX_REQUEST_BODY_SIZE)
|
|
logger.info(f'Request size limiting enabled: {settings.MAX_REQUEST_BODY_SIZE // 1024 // 1024}MB max body size')
|
|
if settings.CSRF_PROTECTION_ENABLED:
|
|
app.add_middleware(CSRFProtectionMiddleware)
|
|
logger.info('CSRF protection enabled')
|
|
if settings.IP_WHITELIST_ENABLED:
|
|
app.add_middleware(AdminIPWhitelistMiddleware)
|
|
logger.info(f'Admin IP whitelisting enabled with {len(settings.ADMIN_IP_WHITELIST)} IP(s)/CIDR range(s)')
|
|
if settings.RATE_LIMIT_ENABLED:
|
|
limiter = Limiter(key_func=get_remote_address, default_limits=[f'{settings.RATE_LIMIT_PER_MINUTE}/minute'])
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
logger.info(f'Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute')
|
|
|
|
# CORS middleware must be added LAST to handle OPTIONS preflight requests before other middleware
|
|
# In FastAPI/Starlette, middleware is executed in reverse order (last added = first executed = outermost)
|
|
# So adding CORS last ensures it wraps all other middleware and handles OPTIONS requests early
|
|
if settings.is_development:
|
|
# More restrictive CORS even in development for better security practices
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?',
|
|
allow_credentials=True,
|
|
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], # Explicit methods
|
|
allow_headers=['Content-Type', 'Authorization', 'X-XSRF-TOKEN', 'X-Requested-With', 'X-Request-ID'] # Explicit headers
|
|
)
|
|
logger.info('CORS configured for development (allowing localhost with explicit methods/headers)')
|
|
else:
|
|
# Validate CORS_ORIGINS in production
|
|
if not settings.CORS_ORIGINS or len(settings.CORS_ORIGINS) == 0:
|
|
logger.warning('CORS_ORIGINS is empty in production. This may block legitimate requests.')
|
|
logger.warning('Please set CORS_ORIGINS environment variable with allowed origins.')
|
|
else:
|
|
# Log CORS configuration for security audit
|
|
logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origin(s)')
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(f'Allowed CORS origins: {", ".join(settings.CORS_ORIGINS)}')
|
|
|
|
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS or [], allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*'])
|
|
uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR
|
|
uploads_dir.mkdir(exist_ok=True)
|
|
app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads')
|
|
app.add_exception_handler(HTTPException, http_exception_handler)
|
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
|
app.add_exception_handler(JWTError, jwt_error_handler)
|
|
app.add_exception_handler(Exception, general_exception_handler)
|
|
|
|
@app.get('/health', tags=['health'])
|
|
@app.get('/api/health', tags=['health'])
|
|
async def health_check(db: Session=Depends(get_db)):
|
|
health_status = {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'checks': {'api': 'ok', 'database': 'unknown'}}
|
|
try:
|
|
from sqlalchemy import text
|
|
db.execute(text('SELECT 1'))
|
|
health_status['checks']['database'] = 'ok'
|
|
except OperationalError as e:
|
|
health_status['status'] = 'unhealthy'
|
|
health_status['checks']['database'] = 'error'
|
|
health_status['error'] = str(e)
|
|
logger.error(f'Database health check failed: {str(e)}')
|
|
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
|
|
except Exception as e:
|
|
health_status['status'] = 'unhealthy'
|
|
health_status['checks']['database'] = 'error'
|
|
health_status['error'] = str(e)
|
|
logger.error(f'Health check failed: {str(e)}')
|
|
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
|
|
return health_status
|
|
|
|
@app.get('/metrics', tags=['monitoring'])
|
|
async def metrics():
|
|
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
|
|
# Import all route modules from feature-based structure
|
|
from .auth.routes import auth_routes, user_routes
|
|
from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes
|
|
from .bookings.routes import booking_routes, group_booking_routes
|
|
from .payments.routes import payment_routes, invoice_routes
|
|
from .hotel_services.routes import service_routes, service_booking_routes
|
|
from .content.routes import (
|
|
banner_routes, page_content_routes, home_routes, about_routes,
|
|
contact_routes, contact_content_routes, footer_routes, privacy_routes,
|
|
admin_privacy_routes, terms_routes, refunds_routes, cancellation_routes,
|
|
accessibility_routes, faq_routes, blog_routes
|
|
)
|
|
from .reviews.routes import review_routes, favorite_routes
|
|
from .loyalty.routes import promotion_routes, loyalty_routes, package_routes
|
|
from .guest_management.routes import guest_profile_routes
|
|
from .notifications.routes import chat_routes, notification_routes, email_campaign_routes
|
|
from .analytics.routes import analytics_routes, report_routes, audit_routes
|
|
from .security.routes import security_routes
|
|
from .system.routes import system_settings_routes, workflow_routes, task_routes
|
|
from .ai.routes import ai_assistant_routes
|
|
|
|
# Register all routes with /api prefix (removed duplicate registrations)
|
|
# Using /api prefix as standard, API versioning can be handled via headers if needed
|
|
api_prefix = '/api'
|
|
app.include_router(auth_routes.router, prefix=api_prefix)
|
|
app.include_router(room_routes.router, prefix=api_prefix)
|
|
app.include_router(booking_routes.router, prefix=api_prefix)
|
|
app.include_router(group_booking_routes.router, prefix=api_prefix)
|
|
app.include_router(payment_routes.router, prefix=api_prefix)
|
|
app.include_router(invoice_routes.router, prefix=api_prefix)
|
|
app.include_router(banner_routes.router, prefix=api_prefix)
|
|
app.include_router(favorite_routes.router, prefix=api_prefix)
|
|
app.include_router(service_routes.router, prefix=api_prefix)
|
|
app.include_router(service_booking_routes.router, prefix=api_prefix)
|
|
app.include_router(promotion_routes.router, prefix=api_prefix)
|
|
app.include_router(report_routes.router, prefix=api_prefix)
|
|
app.include_router(review_routes.router, prefix=api_prefix)
|
|
app.include_router(user_routes.router, prefix=api_prefix)
|
|
app.include_router(audit_routes.router, prefix=api_prefix)
|
|
app.include_router(admin_privacy_routes.router, prefix=api_prefix)
|
|
app.include_router(system_settings_routes.router, prefix=api_prefix)
|
|
app.include_router(contact_routes.router, prefix=api_prefix)
|
|
app.include_router(home_routes.router, prefix=api_prefix)
|
|
app.include_router(about_routes.router, prefix=api_prefix)
|
|
app.include_router(contact_content_routes.router, prefix=api_prefix)
|
|
app.include_router(footer_routes.router, prefix=api_prefix)
|
|
app.include_router(privacy_routes.router, prefix=api_prefix)
|
|
app.include_router(terms_routes.router, prefix=api_prefix)
|
|
app.include_router(refunds_routes.router, prefix=api_prefix)
|
|
app.include_router(cancellation_routes.router, prefix=api_prefix)
|
|
app.include_router(accessibility_routes.router, prefix=api_prefix)
|
|
app.include_router(faq_routes.router, prefix=api_prefix)
|
|
app.include_router(chat_routes.router, prefix=api_prefix)
|
|
app.include_router(loyalty_routes.router, prefix=api_prefix)
|
|
app.include_router(guest_profile_routes.router, prefix=api_prefix)
|
|
app.include_router(analytics_routes.router, prefix=api_prefix)
|
|
app.include_router(workflow_routes.router, prefix=api_prefix)
|
|
app.include_router(task_routes.router, prefix=api_prefix)
|
|
app.include_router(notification_routes.router, prefix=api_prefix)
|
|
app.include_router(advanced_room_routes.router, prefix=api_prefix)
|
|
app.include_router(rate_plan_routes.router, prefix=api_prefix)
|
|
app.include_router(package_routes.router, prefix=api_prefix)
|
|
app.include_router(security_routes.router, prefix=api_prefix)
|
|
app.include_router(email_campaign_routes.router, prefix=api_prefix)
|
|
app.include_router(page_content_routes.router, prefix=api_prefix)
|
|
app.include_router(blog_routes.router, prefix=api_prefix)
|
|
app.include_router(ai_assistant_routes.router, prefix=api_prefix)
|
|
logger.info('All routes registered successfully')
|
|
|
|
def ensure_jwt_secret():
|
|
"""Generate and save JWT secret if it's using the default value.
|
|
|
|
In production, fail fast if default secret is used for security.
|
|
In development, auto-generate a secure secret if needed.
|
|
"""
|
|
default_secret = 'dev-secret-key-change-in-production-12345'
|
|
current_secret = settings.JWT_SECRET
|
|
|
|
# Security check: Fail fast in production if using default secret
|
|
if settings.is_production and (not current_secret or current_secret == default_secret):
|
|
error_msg = (
|
|
'CRITICAL SECURITY ERROR: JWT_SECRET is using default value in production! '
|
|
'Please set a secure JWT_SECRET in your environment variables.'
|
|
)
|
|
logger.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
# Development mode: Auto-generate if needed
|
|
if not current_secret or current_secret == default_secret:
|
|
new_secret = secrets.token_urlsafe(64)
|
|
|
|
os.environ['JWT_SECRET'] = new_secret
|
|
|
|
env_file = Path(__file__).parent.parent / '.env'
|
|
if env_file.exists():
|
|
try:
|
|
env_content = env_file.read_text(encoding='utf-8')
|
|
|
|
jwt_pattern = re.compile(r'^JWT_SECRET=.*$', re.MULTILINE)
|
|
|
|
if jwt_pattern.search(env_content):
|
|
env_content = jwt_pattern.sub(f'JWT_SECRET={new_secret}', env_content)
|
|
else:
|
|
jwt_section_pattern = re.compile(r'(# =+.*JWT.*=+.*\n)', re.IGNORECASE | re.MULTILINE)
|
|
match = jwt_section_pattern.search(env_content)
|
|
if match:
|
|
insert_pos = match.end()
|
|
env_content = env_content[:insert_pos] + f'JWT_SECRET={new_secret}\n' + env_content[insert_pos:]
|
|
else:
|
|
env_content += f'\nJWT_SECRET={new_secret}\n'
|
|
|
|
env_file.write_text(env_content, encoding='utf-8')
|
|
logger.info('✓ JWT secret generated and saved to .env file')
|
|
except Exception as e:
|
|
logger.warning(f'Could not update .env file: {e}')
|
|
logger.info(f'Generated JWT secret (add to .env manually): JWT_SECRET={new_secret}')
|
|
else:
|
|
logger.info(f'Generated JWT secret (add to .env file): JWT_SECRET={new_secret}')
|
|
|
|
logger.info('✓ Secure JWT secret generated automatically')
|
|
else:
|
|
logger.info('✓ JWT secret is configured')
|
|
|
|
@app.on_event('startup')
|
|
async def startup_event():
|
|
ensure_jwt_secret()
|
|
|
|
# Validate encryption key configuration
|
|
try:
|
|
settings.validate_encryption_key()
|
|
except ValueError as e:
|
|
logger.error(str(e))
|
|
if settings.is_production:
|
|
raise # Fail fast in production
|
|
|
|
# Start AI Training Scheduler for automatic self-learning
|
|
try:
|
|
from .ai.services.ai_training_scheduler import get_training_scheduler
|
|
scheduler = get_training_scheduler()
|
|
scheduler.start()
|
|
logger.info('AI Training Scheduler started - automatic self-learning enabled')
|
|
except Exception as e:
|
|
logger.error(f'Failed to start AI Training Scheduler: {str(e)}', exc_info=True)
|
|
# Don't fail app startup if scheduler fails
|
|
|
|
logger.info(f'{settings.APP_NAME} started successfully')
|
|
logger.info(f'Environment: {settings.ENVIRONMENT}')
|
|
logger.info(f'Debug mode: {settings.DEBUG}')
|
|
logger.info(f'API version: {settings.API_V1_PREFIX}')
|
|
|
|
@app.on_event('shutdown')
|
|
async def shutdown_event():
|
|
# Stop AI Training Scheduler
|
|
try:
|
|
from .ai.services.ai_training_scheduler import get_training_scheduler
|
|
scheduler = get_training_scheduler()
|
|
scheduler.stop()
|
|
logger.info('AI Training Scheduler stopped')
|
|
except Exception as e:
|
|
logger.error(f'Error stopping AI Training Scheduler: {str(e)}', exc_info=True)
|
|
|
|
logger.info(f'{settings.APP_NAME} shutting down gracefully')
|
|
if __name__ == '__main__':
|
|
import uvicorn
|
|
from pathlib import Path
|
|
base_dir = Path(__file__).parent.parent
|
|
src_dir = str(base_dir / 'src')
|
|
uvicorn.run('src.main:app', host=settings.HOST, port=settings.PORT, reload=settings.is_development, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if settings.is_development else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=0.5) |