diff --git a/Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc new file mode 100644 index 00000000..43415a7a Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_borica_payment_method.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc index e6927cd9..a773cbb9 100644 Binary files a/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc and b/Backend/alembic/versions/__pycache__/add_rate_plan_id_to_bookings.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/fff4b67466b3_add_account_lockout_fields_to_users.cpython-312.pyc b/Backend/alembic/versions/__pycache__/fff4b67466b3_add_account_lockout_fields_to_users.cpython-312.pyc new file mode 100644 index 00000000..fbe0fbdf Binary files /dev/null and b/Backend/alembic/versions/__pycache__/fff4b67466b3_add_account_lockout_fields_to_users.cpython-312.pyc differ diff --git a/Backend/alembic/versions/fff4b67466b3_add_account_lockout_fields_to_users.py b/Backend/alembic/versions/fff4b67466b3_add_account_lockout_fields_to_users.py new file mode 100644 index 00000000..06e0693d --- /dev/null +++ b/Backend/alembic/versions/fff4b67466b3_add_account_lockout_fields_to_users.py @@ -0,0 +1,29 @@ +"""add_account_lockout_fields_to_users + +Revision ID: fff4b67466b3 +Revises: add_rate_plan_id_001 +Create Date: 2025-11-28 02:26:24.431037 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fff4b67466b3' +down_revision = 'add_rate_plan_id_001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add account lockout fields to users table + op.add_column('users', sa.Column('failed_login_attempts', sa.Integer(), nullable=False, server_default='0')) + op.add_column('users', sa.Column('locked_until', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + # Remove account lockout fields from users table + op.drop_column('users', 'locked_until') + op.drop_column('users', 'failed_login_attempts') + diff --git a/Backend/requirements.txt b/Backend/requirements.txt index fd03ba5a..4b675f61 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -22,6 +22,7 @@ pyotp==2.9.0 qrcode[pil]==7.4.2 httpx==0.25.2 cryptography>=41.0.7 +bleach==6.1.0 # Testing dependencies pytest==7.4.3 diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index cbbe5682..91194500 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/config/__pycache__/settings.cpython-312.pyc b/Backend/src/config/__pycache__/settings.cpython-312.pyc index 3fb66bc8..86f5533b 100644 Binary files a/Backend/src/config/__pycache__/settings.cpython-312.pyc and b/Backend/src/config/__pycache__/settings.cpython-312.pyc differ diff --git a/Backend/src/config/settings.py b/Backend/src/config/settings.py index 2f05da3b..6e0825c4 100644 --- a/Backend/src/config/settings.py +++ b/Backend/src/config/settings.py @@ -20,12 +20,16 @@ class Settings(BaseSettings): JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key') JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm') JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes') - JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days') + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=3, description='JWT refresh token expiration in days (reduced from 7 for better security)') + MAX_LOGIN_ATTEMPTS: int = Field(default=5, description='Maximum failed login attempts before account lockout') + ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(default=30, description='Account lockout duration in minutes after max failed attempts') ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest') CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL') CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins') RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting') RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP') + CSRF_PROTECTION_ENABLED: bool = Field(default=True, description='Enable CSRF protection') + HSTS_PRELOAD_ENABLED: bool = Field(default=False, description='Enable HSTS preload directive (requires domain submission to hstspreload.org)') LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL') LOG_FILE: str = Field(default='logs/app.log', description='Log file path') LOG_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)') @@ -38,6 +42,7 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name') UPLOAD_DIR: str = Field(default='uploads', description='Upload directory') MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)') + MAX_REQUEST_BODY_SIZE: int = Field(default=10485760, description='Max request body size in bytes (10MB)') ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions') REDIS_ENABLED: bool = Field(default=False, description='Enable Redis caching') REDIS_HOST: str = Field(default='localhost', description='Redis host') @@ -76,4 +81,49 @@ class Settings(BaseSettings): if self.REDIS_PASSWORD: return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + + IP_WHITELIST_ENABLED: bool = Field(default=False, description='Enable IP whitelisting for admin endpoints') + ADMIN_IP_WHITELIST: List[str] = Field(default_factory=list, description='List of allowed IP addresses/CIDR ranges for admin endpoints') + + def validate_encryption_key(self) -> None: + """ + Validate encryption key is properly configured. + Raises ValueError if key is missing or invalid in production. + """ + if not self.ENCRYPTION_KEY: + if self.is_production: + raise ValueError( + 'CRITICAL: ENCRYPTION_KEY is not configured in production. ' + 'Please set ENCRYPTION_KEY environment variable to a base64-encoded 32-byte key.' + ) + else: + # In development, warn but don't fail + import logging + logger = logging.getLogger(__name__) + logger.warning( + 'ENCRYPTION_KEY is not configured. Encryption operations may fail. ' + 'Please set ENCRYPTION_KEY environment variable.' + ) + return + + # Validate base64 encoding and key length (32 bytes = 44 base64 chars) + try: + import base64 + decoded = base64.b64decode(self.ENCRYPTION_KEY) + if len(decoded) != 32: + raise ValueError( + f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. ' + f'Received {len(decoded)} bytes after decoding.' + ) + except Exception as e: + if self.is_production: + raise ValueError( + f'Invalid ENCRYPTION_KEY format: {str(e)}. ' + 'Must be a valid base64-encoded 32-byte key.' + ) + else: + import logging + logger = logging.getLogger(__name__) + logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}') + settings = Settings() \ No newline at end of file diff --git a/Backend/src/main.py b/Backend/src/main.py index 920396c4..88219184 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -14,6 +14,7 @@ import sys import secrets import os import re +import logging from .config.settings import settings from .config.logging_config import setup_logging, get_logger from .config.database import engine, Base, get_db @@ -26,6 +27,9 @@ from .middleware.request_id import RequestIDMiddleware from .middleware.security import SecurityHeadersMiddleware from .middleware.timeout import TimeoutMiddleware from .middleware.cookie_consent import CookieConsentMiddleware +from .middleware.csrf import CSRFProtectionMiddleware +from .middleware.request_size_limit import RequestSizeLimitMiddleware +from .middleware.admin_ip_whitelist import AdminIPWhitelistMiddleware if settings.is_development: logger.info('Creating database tables (development mode)') Base.metadata.create_all(bind=engine) @@ -48,6 +52,14 @@ 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 @@ -57,8 +69,17 @@ if settings.is_development: app.add_middleware(CORSMiddleware, allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?', allow_credentials=True, allow_methods=['*'], allow_headers=['*']) logger.info('CORS configured for development (allowing localhost)') else: - app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*']) - logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins') + # 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') @@ -93,93 +114,85 @@ async def health_check(db: Session=Depends(get_db)): @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()} -app.include_router(auth_routes.router, prefix='/api') -app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) -from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, workflow_routes, task_routes, notification_routes, group_booking_routes, advanced_room_routes, rate_plan_routes, package_routes, security_routes, email_campaign_routes -app.include_router(room_routes.router, prefix='/api') -app.include_router(booking_routes.router, prefix='/api') -app.include_router(group_booking_routes.router, prefix='/api') -app.include_router(payment_routes.router, prefix='/api') -app.include_router(invoice_routes.router, prefix='/api') -app.include_router(banner_routes.router, prefix='/api') -app.include_router(favorite_routes.router, prefix='/api') -app.include_router(service_routes.router, prefix='/api') -app.include_router(service_booking_routes.router, prefix='/api') -app.include_router(promotion_routes.router, prefix='/api') -app.include_router(report_routes.router, prefix='/api') -app.include_router(review_routes.router, prefix='/api') -app.include_router(user_routes.router, prefix='/api') -app.include_router(audit_routes.router, prefix='/api') -app.include_router(admin_privacy_routes.router, prefix='/api') -app.include_router(system_settings_routes.router, prefix='/api') -app.include_router(contact_routes.router, prefix='/api') -app.include_router(home_routes.router, prefix='/api') -app.include_router(about_routes.router, prefix='/api') -app.include_router(contact_content_routes.router, prefix='/api') -app.include_router(footer_routes.router, prefix='/api') -app.include_router(privacy_routes.router, prefix='/api') -app.include_router(terms_routes.router, prefix='/api') -app.include_router(refunds_routes.router, prefix='/api') -app.include_router(cancellation_routes.router, prefix='/api') -app.include_router(accessibility_routes.router, prefix='/api') -app.include_router(faq_routes.router, prefix='/api') -app.include_router(chat_routes.router, prefix='/api') -app.include_router(loyalty_routes.router, prefix='/api') -app.include_router(guest_profile_routes.router, prefix='/api') -app.include_router(analytics_routes.router, prefix='/api') -app.include_router(workflow_routes.router, prefix='/api') -app.include_router(task_routes.router, prefix='/api') -app.include_router(notification_routes.router, prefix='/api') -app.include_router(advanced_room_routes.router, prefix='/api') -app.include_router(rate_plan_routes.router, prefix='/api') -app.include_router(package_routes.router, prefix='/api') -app.include_router(security_routes.router, prefix='/api') -app.include_router(email_campaign_routes.router, prefix='/api') -app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(terms_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(refunds_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(analytics_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(workflow_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(task_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(notification_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(advanced_room_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(rate_plan_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(package_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(page_content_routes.router, prefix='/api') -app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX) +# Import all route modules +from .routes import ( + room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, + favorite_routes, service_routes, service_booking_routes, promotion_routes, + report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, + system_settings_routes, contact_routes, page_content_routes, home_routes, + about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, + terms_routes, refunds_routes, cancellation_routes, accessibility_routes, + faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, + workflow_routes, task_routes, notification_routes, group_booking_routes, + advanced_room_routes, rate_plan_routes, package_routes, security_routes, + email_campaign_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) logger.info('All routes registered successfully') def ensure_jwt_secret(): - """Generate and save JWT secret if it's using the default value.""" + """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) @@ -219,6 +232,14 @@ def ensure_jwt_secret(): 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 + logger.info(f'{settings.APP_NAME} started successfully') logger.info(f'Environment: {settings.ENVIRONMENT}') logger.info(f'Debug mode: {settings.DEBUG}') diff --git a/Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc b/Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc new file mode 100644 index 00000000..11f9e10f Binary files /dev/null and b/Backend/src/middleware/__pycache__/admin_ip_whitelist.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/middleware/__pycache__/auth.cpython-312.pyc index 0dc3b94b..19d65f69 100644 Binary files a/Backend/src/middleware/__pycache__/auth.cpython-312.pyc and b/Backend/src/middleware/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc b/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc new file mode 100644 index 00000000..4566c0be Binary files /dev/null and b/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc b/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc index 0d3a2bd5..8cb39fdf 100644 Binary files a/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc and b/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/request_size_limit.cpython-312.pyc b/Backend/src/middleware/__pycache__/request_size_limit.cpython-312.pyc new file mode 100644 index 00000000..ce2b5033 Binary files /dev/null and b/Backend/src/middleware/__pycache__/request_size_limit.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/security.cpython-312.pyc b/Backend/src/middleware/__pycache__/security.cpython-312.pyc index ca0c59c5..e2b4d27e 100644 Binary files a/Backend/src/middleware/__pycache__/security.cpython-312.pyc and b/Backend/src/middleware/__pycache__/security.cpython-312.pyc differ diff --git a/Backend/src/middleware/admin_ip_whitelist.py b/Backend/src/middleware/admin_ip_whitelist.py new file mode 100644 index 00000000..fd0aff19 --- /dev/null +++ b/Backend/src/middleware/admin_ip_whitelist.py @@ -0,0 +1,114 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from typing import List +import ipaddress +from ..config.settings import settings +from ..config.logging_config import get_logger + +logger = get_logger(__name__) + +class AdminIPWhitelistMiddleware(BaseHTTPMiddleware): + """ + Middleware to enforce IP whitelisting for admin endpoints. + Only applies to routes starting with /api/admin/ or containing 'admin' in path. + """ + + def __init__(self, app, enabled: bool = None, whitelist: List[str] = None): + super().__init__(app) + self.enabled = enabled if enabled is not None else settings.IP_WHITELIST_ENABLED + self.whitelist = whitelist if whitelist is not None else settings.ADMIN_IP_WHITELIST + + # Pre-compile IP networks for faster lookup + self._compiled_networks = [] + if self.enabled and self.whitelist: + for ip_or_cidr in self.whitelist: + try: + if '/' in ip_or_cidr: + # CIDR notation + network = ipaddress.ip_network(ip_or_cidr, strict=False) + self._compiled_networks.append(network) + else: + # Single IP address + ip = ipaddress.ip_address(ip_or_cidr) + # Convert to /32 network for consistent handling + self._compiled_networks.append(ipaddress.ip_network(f'{ip}/32', strict=False)) + except (ValueError, ipaddress.AddressValueError) as e: + logger.warning(f'Invalid IP/CIDR in admin whitelist: {ip_or_cidr} - {str(e)}') + + if self.enabled: + logger.info(f'Admin IP whitelisting enabled with {len(self._compiled_networks)} allowed IP(s)/CIDR range(s)') + + def _is_admin_route(self, path: str) -> bool: + """Check if the path is an admin route""" + return '/admin/' in path.lower() or path.lower().startswith('/api/admin') + + def _get_client_ip(self, request: Request) -> str: + """Extract client IP address from request""" + # Check for forwarded IP (when behind proxy/load balancer) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # X-Forwarded-For can contain multiple IPs, take the first one (original client) + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + + # Fallback to direct client IP + if request.client: + return request.client.host + + return None + + def _is_ip_allowed(self, ip_address: str) -> bool: + """Check if IP address is in whitelist""" + if not self._compiled_networks: + # Empty whitelist means deny all (security-first approach) + return False + + try: + client_ip = ipaddress.ip_address(ip_address) + for network in self._compiled_networks: + if client_ip in network: + return True + return False + except (ValueError, ipaddress.AddressValueError): + logger.warning(f'Invalid IP address format: {ip_address}') + return False + + async def dispatch(self, request: Request, call_next): + # Skip if not enabled + if not self.enabled: + return await call_next(request) + + # Only apply to admin routes + if not self._is_admin_route(request.url.path): + return await call_next(request) + + # Skip IP check for health checks and public endpoints + if request.url.path in ['/health', '/api/health', '/metrics']: + return await call_next(request) + + client_ip = self._get_client_ip(request) + + if not client_ip: + logger.warning("Could not determine client IP address for admin route") + # Deny by default if IP cannot be determined (security-first) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Access denied: Unable to verify IP address"} + ) + + # Check whitelist + if not self._is_ip_allowed(client_ip): + logger.warning( + f"Admin route access denied for IP: {client_ip} from path: {request.url.path}" + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Access denied. IP address not whitelisted."} + ) + + # IP is whitelisted, continue + return await call_next(request) diff --git a/Backend/src/middleware/auth.py b/Backend/src/middleware/auth.py index fe895692..7531f4a8 100644 --- a/Backend/src/middleware/auth.py +++ b/Backend/src/middleware/auth.py @@ -10,17 +10,48 @@ from ..models.user import User from ..models.role import Role security = HTTPBearer() +def get_jwt_secret() -> str: + """ + Get JWT secret securely, fail if not configured. + Never use hardcoded fallback secrets. + """ + default_secret = 'dev-secret-key-change-in-production-12345' + jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', None) + + # Fail fast if secret is not configured or using default value + if not jwt_secret or jwt_secret == default_secret: + if settings.is_production: + raise ValueError( + 'CRITICAL: JWT_SECRET is not properly configured in production. ' + 'Please set JWT_SECRET environment variable to a secure random string.' + ) + # In development, warn but allow (startup validation should catch this) + import warnings + warnings.warn( + f'JWT_SECRET not configured. Using settings value but this is insecure. ' + f'Set JWT_SECRET environment variable.', + UserWarning + ) + jwt_secret = getattr(settings, 'JWT_SECRET', None) + if not jwt_secret: + raise ValueError('JWT_SECRET must be configured') + + return jwt_secret + def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User: token = credentials.credentials credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'}) try: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) user_id: int = payload.get('userId') if user_id is None: raise credentials_exception except JWTError: raise credentials_exception + except ValueError as e: + # JWT secret configuration error - should not happen in production + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Server configuration error') user = db.query(User).filter(User.id == user_id).first() if user is None: raise credentials_exception @@ -43,17 +74,17 @@ def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials return None token = credentials.credentials try: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) user_id: int = payload.get('userId') if user_id is None: return None - except JWTError: + except (JWTError, ValueError): return None user = db.query(User).filter(User.id == user_id).first() return user def verify_token(token: str) -> dict: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + jwt_secret = get_jwt_secret() payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) return payload \ No newline at end of file diff --git a/Backend/src/middleware/csrf.py b/Backend/src/middleware/csrf.py new file mode 100644 index 00000000..2b162cd4 --- /dev/null +++ b/Backend/src/middleware/csrf.py @@ -0,0 +1,158 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from typing import Optional +import secrets +import hmac +import hashlib +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + +# Safe HTTP methods that don't require CSRF protection +SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS'} + + +class CSRFProtectionMiddleware(BaseHTTPMiddleware): + """ + CSRF Protection Middleware + + Validates CSRF tokens for state-changing requests (POST, PUT, DELETE, PATCH). + Uses Double Submit Cookie pattern for stateless CSRF protection. + """ + + CSRF_TOKEN_COOKIE_NAME = 'XSRF-TOKEN' + CSRF_TOKEN_HEADER_NAME = 'X-XSRF-TOKEN' + CSRF_SECRET_LENGTH = 32 + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Skip CSRF protection for certain endpoints that don't need it + # (e.g., public APIs, webhooks with their own validation) + is_exempt = self._is_exempt_path(path) + + # Get or generate CSRF token (always generate for all requests to ensure cookie is set) + csrf_token = request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME) + if not csrf_token: + csrf_token = self._generate_token() + + # Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) and exempt paths + if request.method in SAFE_METHODS or is_exempt: + response = await call_next(request) + else: + # For state-changing requests, validate the token + if request.method in {'POST', 'PUT', 'DELETE', 'PATCH'}: + header_token = request.headers.get(self.CSRF_TOKEN_HEADER_NAME) + + if not header_token: + logger.warning(f"CSRF token missing in header for {request.method} {path}") + # Create error response with CSRF cookie set so frontend can retry + from fastapi.responses import JSONResponse + error_response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "CSRF token missing. Please include X-XSRF-TOKEN header."} + ) + # Set cookie even on error so client can get the token and retry + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + error_response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, + secure=settings.is_production, + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, + path='/' + ) + return error_response + + # Validate token using constant-time comparison + if not self._verify_token(csrf_token, header_token): + logger.warning(f"CSRF token validation failed for {request.method} {path}") + # Create error response with CSRF cookie set so frontend can retry + from fastapi.responses import JSONResponse + error_response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"status": "error", "message": "Invalid CSRF token. Please refresh the page and try again."} + ) + # Set cookie even on error so client can get the token and retry + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + error_response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, + secure=settings.is_production, + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, + path='/' + ) + return error_response + + # Process request + response = await call_next(request) + + # Always set CSRF token cookie if not present (ensures client always has it) + # This allows frontend to read it from cookies for subsequent requests + if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME): + # Set secure cookie with SameSite protection + response.set_cookie( + key=self.CSRF_TOKEN_COOKIE_NAME, + value=csrf_token, + httponly=False, # Must be accessible to JavaScript for header submission + secure=settings.is_production, # HTTPS only in production + samesite='lax', # Changed to 'lax' for better cross-origin support + max_age=86400 * 7, # 7 days + path='/' + ) + + return response + + def _is_exempt_path(self, path: str) -> bool: + """ + Check if path is exempt from CSRF protection. + + Exempt paths: + - Authentication endpoints (login, register, logout, refresh token) + - Webhook endpoints (they have their own signature validation) + - Health check endpoints + - Static file endpoints + """ + exempt_patterns = [ + '/api/auth/', # All authentication endpoints + '/api/webhooks/', + '/api/stripe/webhook', + '/api/payments/stripe/webhook', + '/api/paypal/webhook', + '/health', + '/api/health', + '/static/', + '/docs', + '/redoc', + '/openapi.json' + ] + + return any(path.startswith(pattern) for pattern in exempt_patterns) + + def _generate_token(self) -> str: + """Generate a secure random CSRF token.""" + return secrets.token_urlsafe(self.CSRF_SECRET_LENGTH) + + def _verify_token(self, cookie_token: str, header_token: str) -> bool: + """ + Verify CSRF token using constant-time comparison. + + Uses the Double Submit Cookie pattern - the token in the cookie + must match the token in the header. + """ + if not cookie_token or not header_token: + return False + + # Constant-time comparison to prevent timing attacks + return hmac.compare_digest(cookie_token, header_token) + + +def get_csrf_token(request: Request) -> Optional[str]: + """Helper function to get CSRF token from request cookies.""" + return request.cookies.get(CSRFProtectionMiddleware.CSRF_TOKEN_COOKIE_NAME) + diff --git a/Backend/src/middleware/error_handler.py b/Backend/src/middleware/error_handler.py index c05e3ec8..fd07f125 100644 --- a/Backend/src/middleware/error_handler.py +++ b/Backend/src/middleware/error_handler.py @@ -4,6 +4,30 @@ from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError from jose.exceptions import JWTError import traceback +import os +from ..utils.response_helpers import error_response +from ..config.settings import settings + +def _add_cors_headers(response: JSONResponse, request: Request) -> JSONResponse: + """Add CORS headers to response for cross-origin requests.""" + origin = request.headers.get('Origin') + if origin: + # Check if origin is allowed (development or production) + if settings.is_development: + # Allow localhost origins in development + if origin.startswith('http://localhost') or origin.startswith('http://127.0.0.1'): + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = '*' + else: + # In production, check against CORS_ORIGINS + if origin in settings.CORS_ORIGINS: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = '*' + return response async def validation_exception_handler(request: Request, exc: RequestValidationError): errors = [] @@ -11,25 +35,69 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body')) errors.append({'field': field, 'message': error['msg']}) first_error = errors[0]['message'] if errors else 'Validation error' - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': first_error, 'errors': errors}) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + response_content = error_response( + message=first_error, + errors=errors, + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def integrity_error_handler(request: Request, exc: IntegrityError): error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None if 'Duplicate entry' in error_msg or 'UNIQUE constraint' in error_msg: - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Duplicate entry', 'errors': [{'message': 'This record already exists'}]}) - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Database integrity error'}) + response_content = error_response( + message='Duplicate entry', + errors=[{'message': 'This record already exists'}], + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + else: + response_content = error_response( + message='Database integrity error', + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def jwt_error_handler(request: Request, exc: JWTError): - return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={'status': 'error', 'message': 'Invalid token'}) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + response_content = error_response( + message='Invalid token', + request_id=request_id + ) + response = JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def http_exception_handler(request: Request, exc: HTTPException): + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None if isinstance(exc.detail, dict): - return JSONResponse(status_code=exc.status_code, content=exc.detail) - return JSONResponse(status_code=exc.status_code, content={'status': 'error', 'message': str(exc.detail) if exc.detail else 'An error occurred'}) + response_content = exc.detail.copy() + if request_id and 'request_id' not in response_content: + response_content['request_id'] = request_id + # Ensure it has standard error response format + if 'status' not in response_content: + response_content['status'] = 'error' + if 'success' not in response_content: + response_content['success'] = False + response = JSONResponse(status_code=exc.status_code, content=response_content) + else: + response_content = error_response( + message=str(exc.detail) if exc.detail else 'An error occurred', + request_id=request_id + ) + response = JSONResponse(status_code=exc.status_code, content=response_content) + + # Add CORS headers to error responses + return _add_cors_headers(response, request) async def general_exception_handler(request: Request, exc: Exception): from ..config.logging_config import get_logger - from ..config.settings import settings logger = get_logger(__name__) request_id = getattr(request.state, 'request_id', None) logger.error(f'Unhandled exception: {type(exc).__name__}: {str(exc)}', extra={'request_id': request_id, 'path': request.url.path, 'method': request.method, 'exception_type': type(exc).__name__}, exc_info=True) @@ -38,14 +106,30 @@ async def general_exception_handler(request: Request, exc: Exception): if hasattr(exc, 'detail'): detail = exc.detail if isinstance(detail, dict): - return JSONResponse(status_code=status_code, content=detail) + response = JSONResponse(status_code=status_code, content=detail) + return _add_cors_headers(response, request) message = str(detail) if detail else 'An error occurred' else: message = str(exc) if str(exc) else 'Internal server error' else: status_code = status.HTTP_500_INTERNAL_SERVER_ERROR message = str(exc) if str(exc) else 'Internal server error' - response_content = {'status': 'error', 'message': message} + response_content = error_response( + message=message, + request_id=request_id + ) + # NEVER include stack traces in production responses + # Always log stack traces server-side only for debugging if settings.is_development: - response_content['stack'] = traceback.format_exc() - return JSONResponse(status_code=status_code, content=response_content) \ No newline at end of file + # Only include stack traces in development mode + # Double-check environment to prevent accidental exposure + env_check = os.getenv('ENVIRONMENT', 'development').lower() + if env_check == 'development': + response_content['stack'] = traceback.format_exc() + else: + # Log warning if development flag is set but environment says otherwise + logger.warning(f'is_development=True but ENVIRONMENT={env_check}. Not including stack trace in response.') + # Stack traces are always logged server-side via exc_info=True above + response = JSONResponse(status_code=status_code, content=response_content) + # Add CORS headers to error responses + return _add_cors_headers(response, request) \ No newline at end of file diff --git a/Backend/src/middleware/request_size_limit.py b/Backend/src/middleware/request_size_limit.py new file mode 100644 index 00000000..4006dac2 --- /dev/null +++ b/Backend/src/middleware/request_size_limit.py @@ -0,0 +1,53 @@ +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + """ + Middleware to enforce maximum request body size limits. + + Prevents DoS attacks by rejecting requests that exceed the configured + maximum body size before they are processed. + """ + + def __init__(self, app, max_size: int = None): + super().__init__(app) + self.max_size = max_size or settings.MAX_REQUEST_BODY_SIZE + + async def dispatch(self, request: Request, call_next): + # Skip size check for methods that don't have bodies + if request.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE']: + return await call_next(request) + + # Check Content-Length header if available + content_length = request.headers.get('content-length') + if content_length: + try: + size = int(content_length) + if size > self.max_size: + logger.warning( + f"Request body size {size} bytes exceeds maximum {self.max_size} bytes " + f"from {request.client.host if request.client else 'unknown'}" + ) + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={ + 'status': 'error', + 'message': f'Request body too large. Maximum size: {self.max_size // 1024 // 1024}MB' + } + ) + except (ValueError, TypeError): + # Invalid content-length header, let it pass and let FastAPI handle it + pass + + # For streaming requests without Content-Length, we need to check the body + # This is handled by limiting the body read size + response = await call_next(request) + + return response + diff --git a/Backend/src/middleware/security.py b/Backend/src/middleware/security.py index 9dfe8e2a..4f6b9bb3 100644 --- a/Backend/src/middleware/security.py +++ b/Backend/src/middleware/security.py @@ -12,9 +12,29 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): security_headers = {'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()'} security_headers.setdefault('Cross-Origin-Resource-Policy', 'cross-origin') if settings.is_production: - security_headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'" - if settings.is_production: - security_headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + # Enhanced CSP with additional directives + # Note: unsafe-inline and unsafe-eval are kept for React/Vite compatibility + # Consider moving to nonces/hashes in future for stricter policy + security_headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "upgrade-insecure-requests" + ) + # HSTS with preload directive (only add preload if domain is ready for it) + # Preload requires manual submission to hstspreload.org + # Include preload directive only if explicitly enabled + hsts_directive = 'max-age=31536000; includeSubDomains' + if getattr(settings, 'HSTS_PRELOAD_ENABLED', False): + hsts_directive += '; preload' + security_headers['Strict-Transport-Security'] = hsts_directive for header, value in security_headers.items(): response.headers[header] = value return response \ No newline at end of file diff --git a/Backend/src/models/__pycache__/booking.cpython-312.pyc b/Backend/src/models/__pycache__/booking.cpython-312.pyc index 4ef81650..487efac1 100644 Binary files a/Backend/src/models/__pycache__/booking.cpython-312.pyc and b/Backend/src/models/__pycache__/booking.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/invoice.cpython-312.pyc b/Backend/src/models/__pycache__/invoice.cpython-312.pyc index 7d68a536..46ee8f6f 100644 Binary files a/Backend/src/models/__pycache__/invoice.cpython-312.pyc and b/Backend/src/models/__pycache__/invoice.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/payment.cpython-312.pyc b/Backend/src/models/__pycache__/payment.cpython-312.pyc index f30adc21..9b7a4f29 100644 Binary files a/Backend/src/models/__pycache__/payment.cpython-312.pyc and b/Backend/src/models/__pycache__/payment.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/review.cpython-312.pyc b/Backend/src/models/__pycache__/review.cpython-312.pyc index 567026af..76da59e6 100644 Binary files a/Backend/src/models/__pycache__/review.cpython-312.pyc and b/Backend/src/models/__pycache__/review.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index 5419cfbe..19d96464 100644 Binary files a/Backend/src/models/__pycache__/user.cpython-312.pyc and b/Backend/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/models/booking.py b/Backend/src/models/booking.py index ba60fa6d..e8a32754 100644 --- a/Backend/src/models/booking.py +++ b/Backend/src/models/booking.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey, Index from sqlalchemy.orm import relationship from datetime import datetime import enum @@ -15,10 +15,10 @@ class Booking(Base): __tablename__ = 'bookings' id = Column(Integer, primary_key=True, index=True, autoincrement=True) booking_number = Column(String(50), unique=True, nullable=False, index=True) - user_id = Column(Integer, ForeignKey('users.id'), nullable=False) - room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) - check_in_date = Column(DateTime, nullable=False) - check_out_date = Column(DateTime, nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True) + check_in_date = Column(DateTime, nullable=False, index=True) + check_out_date = Column(DateTime, nullable=False, index=True) num_guests = Column(Integer, nullable=False, default=1) total_price = Column(Numeric(10, 2), nullable=False) original_price = Column(Numeric(10, 2), nullable=True) @@ -39,4 +39,10 @@ class Booking(Base): group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=True) group_booking = relationship('GroupBooking', back_populates='individual_bookings') rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=True) - rate_plan = relationship('RatePlan', back_populates='bookings') \ No newline at end of file + rate_plan = relationship('RatePlan', back_populates='bookings') + + # Composite index for date range queries (availability checks) + __table_args__ = ( + Index('idx_booking_dates', 'check_in_date', 'check_out_date'), + Index('idx_booking_user_dates', 'user_id', 'check_in_date', 'check_out_date'), + ) \ No newline at end of file diff --git a/Backend/src/models/invoice.py b/Backend/src/models/invoice.py index 0372c629..f5e18d14 100644 --- a/Backend/src/models/invoice.py +++ b/Backend/src/models/invoice.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean, Index from sqlalchemy.orm import relationship from datetime import datetime import enum @@ -15,8 +15,8 @@ class Invoice(Base): __tablename__ = 'invoices' id = Column(Integer, primary_key=True, index=True, autoincrement=True) invoice_number = Column(String(50), unique=True, nullable=False, index=True) - booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) - user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) issue_date = Column(DateTime, default=datetime.utcnow, nullable=False) due_date = Column(DateTime, nullable=False) paid_date = Column(DateTime, nullable=True) @@ -52,6 +52,13 @@ class Invoice(Base): created_by = relationship('User', foreign_keys=[created_by_id]) updated_by = relationship('User', foreign_keys=[updated_by_id]) items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan') + + # Index for invoice status and date queries + __table_args__ = ( + Index('idx_invoice_status', 'status'), + Index('idx_invoice_user_status', 'user_id', 'status'), + Index('idx_invoice_due_date', 'due_date'), + ) class InvoiceItem(Base): __tablename__ = 'invoice_items' diff --git a/Backend/src/models/payment.py b/Backend/src/models/payment.py index 35cd26b5..883b3e7e 100644 --- a/Backend/src/models/payment.py +++ b/Backend/src/models/payment.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Index from sqlalchemy.orm import relationship from datetime import datetime import enum @@ -28,7 +28,7 @@ class PaymentStatus(str, enum.Enum): class Payment(Base): __tablename__ = 'payments' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True) amount = Column(Numeric(10, 2), nullable=False) payment_method = Column(Enum(PaymentMethod), nullable=False) payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full) @@ -41,4 +41,10 @@ class Payment(Base): created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) booking = relationship('Booking', back_populates='payments') - related_payment = relationship('Payment', remote_side=[id], backref='related_payments') \ No newline at end of file + related_payment = relationship('Payment', remote_side=[id], backref='related_payments') + + # Index for payment status queries + __table_args__ = ( + Index('idx_payment_status', 'payment_status'), + Index('idx_payment_booking_status', 'booking_id', 'payment_status'), + ) \ No newline at end of file diff --git a/Backend/src/models/review.py b/Backend/src/models/review.py index e7693f43..a5338a59 100644 --- a/Backend/src/models/review.py +++ b/Backend/src/models/review.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, Text, Enum, ForeignKey, DateTime +from sqlalchemy import Column, Integer, Text, Enum, ForeignKey, DateTime, Index from sqlalchemy.orm import relationship from datetime import datetime import enum @@ -12,12 +12,18 @@ class ReviewStatus(str, enum.Enum): class Review(Base): __tablename__ = 'reviews' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey('users.id'), nullable=False) - room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True) rating = Column(Integer, nullable=False) comment = Column(Text, nullable=False) status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) user = relationship('User', back_populates='reviews') - room = relationship('Room', back_populates='reviews') \ No newline at end of file + room = relationship('Room', back_populates='reviews') + + # Index for review status and room queries + __table_args__ = ( + Index('idx_review_status', 'status'), + Index('idx_review_room_status', 'room_id', 'status'), + ) \ No newline at end of file diff --git a/Backend/src/models/user.py b/Backend/src/models/user.py index e5d87a09..648c895c 100644 --- a/Backend/src/models/user.py +++ b/Backend/src/models/user.py @@ -19,6 +19,10 @@ class User(Base): mfa_secret = Column(String(255), nullable=True) mfa_backup_codes = Column(Text, nullable=True) + # Account lockout fields + failed_login_attempts = Column(Integer, nullable=False, default=0) + locked_until = Column(DateTime, nullable=True) + # Guest Profile & CRM fields is_vip = Column(Boolean, nullable=False, default=False) lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest diff --git a/Backend/src/routes/__pycache__/advanced_room_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/advanced_room_routes.cpython-312.pyc index 1adfb833..db9eec3d 100644 Binary files a/Backend/src/routes/__pycache__/advanced_room_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/advanced_room_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc index cd0d76dd..6f0690b5 100644 Binary files a/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc index 76a96a60..2c5281a7 100644 Binary files a/Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index 418b1b2b..879a25c9 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc index fa57a241..6ae10d60 100644 Binary files a/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc index 0ecf3efd..a1e5a7eb 100644 Binary files a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc index 009ca470..d897a559 100644 Binary files a/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc index c767158d..6864a7a5 100644 Binary files a/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 6943c264..c8b8e0d3 100644 Binary files a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc index 338240fb..e154744d 100644 Binary files a/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc index ba718617..9717940f 100644 Binary files a/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc index 3b3b020e..7928a89e 100644 Binary files a/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/room_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc index 8f06fd71..ce3c466a 100644 Binary files a/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/service_booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/user_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/user_routes.cpython-312.pyc index 2921dc4f..b0a7b90f 100644 Binary files a/Backend/src/routes/__pycache__/user_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/user_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/advanced_room_routes.py b/Backend/src/routes/advanced_room_routes.py index eca434f6..d9ceee4d 100644 --- a/Backend/src/routes/advanced_room_routes.py +++ b/Backend/src/routes/advanced_room_routes.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc from typing import List, Optional from datetime import datetime, timedelta from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.role import Role @@ -17,6 +18,7 @@ from ..services.room_assignment_service import RoomAssignmentService from pydantic import BaseModel from typing import Dict, Any +logger = get_logger(__name__) router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management']) @@ -468,9 +470,9 @@ async def create_housekeeping_task( try: await manager.staff_connections[assigned_to].send_json(notification_data) except Exception as e: - print(f'Error sending housekeeping task notification to staff {assigned_to}: {e}') + logger.error(f'Error sending housekeeping task notification to staff {assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': assigned_to}) except Exception as e: - print(f'Error setting up housekeeping task notification: {e}') + logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', @@ -577,9 +579,9 @@ async def update_housekeeping_task( try: await manager.staff_connections[task.assigned_to].send_json(notification_data) except Exception as e: - print(f'Error sending housekeeping task notification to staff {task.assigned_to}: {e}') + logger.error(f'Error sending housekeeping task notification to staff {task.assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': task.assigned_to}) except Exception as e: - print(f'Error setting up housekeeping task notification: {e}') + logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', diff --git a/Backend/src/routes/auth_routes.py b/Backend/src/routes/auth_routes.py index 0e8ba0a0..f2e2e3b6 100644 --- a/Backend/src/routes/auth_routes.py +++ b/Backend/src/routes/auth_routes.py @@ -10,8 +10,29 @@ from ..services.auth_service import auth_service from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse from ..middleware.auth import get_current_user from ..models.user import User +from ..services.audit_service import audit_service +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + router = APIRouter(prefix='/auth', tags=['auth']) +# Stricter rate limits for authentication endpoints +AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP +PASSWORD_RESET_LIMIT = "3/hour" # 3 password reset requests per hour per IP +LOGIN_RATE_LIMIT = "10/minute" # 10 login attempts per minute per IP + +def get_limiter(request: Request) -> Limiter: + """Get limiter instance from app state.""" + return request.app.state.limiter if hasattr(request.app.state, 'limiter') else None + +def apply_rate_limit(func, limit_value: str): + """Helper to apply rate limiting decorator if limiter is available.""" + def decorator(*args, **kwargs): + # This will be applied at runtime when route is called + return func(*args, **kwargs) + return decorator + def get_base_url(request: Request) -> str: return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}' @@ -25,27 +46,133 @@ def normalize_image_url(image_url: str, base_url: str) -> str: return f'{base_url}/{image_url}' @router.post('/register', status_code=status.HTTP_201_CREATED) -async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)): +async def register( + request: Request, + register_request: RegisterRequest, + response: Response, + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone) - response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/') + result = await auth_service.register(db=db, name=register_request.name, email=register_request.email, password=register_request.password, phone=register_request.phone) + from ..config.settings import settings + # Use secure cookies in production (HTTPS required) + response.set_cookie( + key='refreshToken', + value=result['refreshToken'], + httponly=True, + secure=settings.is_production, # Secure flag enabled in production + samesite='strict', + max_age=7 * 24 * 60 * 60, + path='/' + ) + + # Log successful registration + await audit_service.log_action( + db=db, + action='user_registered', + resource_type='user', + user_id=result['user']['id'], + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': register_request.email, 'name': register_request.name}, + status='success' + ) + return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) + # Log failed registration attempt + await audit_service.log_action( + db=db, + action='user_registration_failed', + resource_type='user', + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': register_request.email, 'name': register_request.name}, + status='failed', + error_message=error_message + ) return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': error_message}) @router.post('/login') -async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)): +async def login( + request: Request, + login_request: LoginRequest, + response: Response, + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken) + result = await auth_service.login(db=db, email=login_request.email, password=login_request.password, remember_me=login_request.rememberMe or False, mfa_token=login_request.mfaToken) if result.get('requires_mfa'): + # Log MFA required + user = db.query(User).filter(User.email == login_request.email.lower().strip()).first() + if user: + await audit_service.log_action( + db=db, + action='login_mfa_required', + resource_type='authentication', + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email}, + status='success' + ) return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} - max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60 - response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/') + from ..config.settings import settings + max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60 + # Use secure cookies in production (HTTPS required) + response.set_cookie( + key='refreshToken', + value=result['refreshToken'], + httponly=True, + secure=settings.is_production, # Secure flag enabled in production + samesite='strict', + max_age=max_age, + path='/' + ) + + # Log successful login + await audit_service.log_action( + db=db, + action='login_success', + resource_type='authentication', + user_id=result['user']['id'], + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'remember_me': login_request.rememberMe}, + status='success' + ) + return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST + + # Log failed login attempt + await audit_service.log_action( + db=db, + action='login_failed', + resource_type='authentication', + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email}, + status='failed', + error_message=error_message + ) + return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message}) @router.post('/refresh-token', response_model=TokenResponse) @@ -59,10 +186,34 @@ async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) @router.post('/logout', response_model=MessageResponse) -async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)): +async def logout( + request: Request, + response: Response, + refreshToken: str=Cookie(None), + current_user: User=Depends(get_current_user), + db: Session=Depends(get_db) +): + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + if refreshToken: await auth_service.logout(db, refreshToken) response.delete_cookie(key='refreshToken', path='/') + + # Log logout + await audit_service.log_action( + db=db, + action='logout', + resource_type='authentication', + user_id=current_user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': current_user.email}, + status='success' + ) + return {'status': 'success', 'message': 'Logout successful'} @router.get('/profile') @@ -164,11 +315,12 @@ async def regenerate_backup_codes(current_user: User=Depends(get_current_user), @router.post('/avatar/upload') async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - if not image.content_type or not image.content_type.startswith('image/'): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image') - content = await image.read() - if len(content) > 2 * 1024 * 1024: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Avatar file size must be less than 2MB') + # Use comprehensive file validation (magic bytes + size) + from ..utils.file_validation import validate_uploaded_image + max_avatar_size = 2 * 1024 * 1024 # 2MB for avatars + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_avatar_size) upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars' upload_dir.mkdir(parents=True, exist_ok=True) if current_user.avatar: diff --git a/Backend/src/routes/banner_routes.py b/Backend/src/routes/banner_routes.py index 400940e2..92fb4d9d 100644 --- a/Backend/src/routes/banner_routes.py +++ b/Backend/src/routes/banner_routes.py @@ -135,10 +135,15 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur ext = Path(image.filename).suffix or '.jpg' filename = f'banner-{uuid.uuid4()}{ext}' file_path = upload_dir / filename + # Use comprehensive file validation (magic bytes + size) + from ..config.settings import settings + from ..utils.file_validation import validate_uploaded_image + max_size = settings.MAX_UPLOAD_SIZE + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_size) + async with aiofiles.open(file_path, 'wb') as f: - content = await image.read() - if not content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) image_url = f'/uploads/banners/{filename}' base_url = get_base_url(request) diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 32593540..c2d00b62 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -24,6 +24,7 @@ from ..utils.email_templates import booking_confirmation_email_template, booking from ..services.loyalty_service import LoyaltyService from ..utils.currency_helpers import get_currency_symbol from ..utils.response_helpers import success_response +from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest router = APIRouter(prefix='/bookings', tags=['bookings']) def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: @@ -159,47 +160,45 @@ async def get_my_bookings(request: Request, current_user: User=Depends(get_curre booking_dict['payments'] = [] result.append(booking_dict) return success_response(data={'bookings': result}) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + logger.error(f'Error in get_my_bookings: {str(e)}', extra={'request_id': request_id, 'user_id': current_user.id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching bookings') @router.post('/') -async def create_booking(booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_booking(booking_data: CreateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + """Create a new booking with validated input using Pydantic schema.""" role = db.query(Role).filter(Role.id == current_user.role_id).first() if role and role.name in ['admin', 'staff', 'accountant']: raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings') try: import logging logger = logging.getLogger(__name__) - if not isinstance(booking_data, dict): - logger.error(f'Invalid booking_data type: {type(booking_data)}, value: {booking_data}') - raise HTTPException(status_code=400, detail='Invalid request body. Expected JSON object.') - logger.info(f'Received booking request from user {current_user.id}: {booking_data}') - room_id = booking_data.get('room_id') - check_in_date = booking_data.get('check_in_date') - check_out_date = booking_data.get('check_out_date') - total_price = booking_data.get('total_price') - guest_count = booking_data.get('guest_count', 1) - notes = booking_data.get('notes') - payment_method = booking_data.get('payment_method', 'cash') - promotion_code = booking_data.get('promotion_code') - referral_code = booking_data.get('referral_code') - invoice_info = booking_data.get('invoice_info', {}) - missing_fields = [] - if not room_id: - missing_fields.append('room_id') - if not check_in_date: - missing_fields.append('check_in_date') - if not check_out_date: - missing_fields.append('check_out_date') - if total_price is None: - missing_fields.append('total_price') - if missing_fields: - error_msg = f'Missing required booking fields: {', '.join(missing_fields)}' - logger.error(error_msg) - raise HTTPException(status_code=400, detail=error_msg) + logger.info(f'Received booking request from user {current_user.id}: {booking_data.dict()}') + + # Extract validated data from Pydantic model + room_id = booking_data.room_id + check_in_date = booking_data.check_in_date + check_out_date = booking_data.check_out_date + total_price = booking_data.total_price + guest_count = booking_data.guest_count + notes = booking_data.notes + payment_method = booking_data.payment_method + promotion_code = booking_data.promotion_code + referral_code = booking_data.referral_code + services = booking_data.services or [] + invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {} + room = db.query(Room).filter(Room.id == room_id).first() if not room: raise HTTPException(status_code=404, detail='Room not found') + + # Parse dates (schema validation already ensures format is valid) if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) else: @@ -208,6 +207,8 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) else: check_out = datetime.strptime(check_out_date, '%Y-%m-%d') + + # Date validation already done in schema, but keeping as safety check if check_in >= check_out: raise HTTPException(status_code=400, detail='Check-out date must be after check-in date') overlapping = db.query(Booking).filter(and_(Booking.room_id == room_id, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first() @@ -247,18 +248,16 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr number_of_nights = 1 # Minimum 1 night room_total = room_price * number_of_nights - # Calculate services total if any - services = booking_data.get('services', []) + # Calculate services total if any (using Pydantic model) services_total = 0.0 if services: from ..models.service import Service for service_item in services: - service_id = service_item.get('service_id') - quantity = service_item.get('quantity', 1) - if service_id: - service = db.query(Service).filter(Service.id == service_id).first() - if service and service.is_active: - services_total += float(service.price) * quantity + service_id = service_item.service_id + quantity = service_item.quantity + service = db.query(Service).filter(Service.id == service_id).first() + if service and service.is_active: + services_total += float(service.price) * quantity original_price = room_total + services_total @@ -358,14 +357,12 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr db.add(deposit_payment) db.flush() logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%') - services = booking_data.get('services', []) + # Add service usages (services already extracted from Pydantic model) if services: from ..models.service import Service for service_item in services: - service_id = service_item.get('service_id') - quantity = service_item.get('quantity', 1) - if not service_id: - continue + service_id = service_item.service_id + quantity = service_item.quantity service = db.query(Service).filter(Service.id == service_id).first() if not service or not service.is_active: continue @@ -559,7 +556,12 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + logger.error(f'Error in get_booking_by_id: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching booking') @router.patch('/{id}/cancel') async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): @@ -645,7 +647,7 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user), raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))]) -async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: booking = db.query(Booking).options( selectinload(Booking.payments), @@ -654,7 +656,7 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends if not booking: raise HTTPException(status_code=404, detail='Booking not found') old_status = booking.status - status_value = booking_data.get('status') + status_value = booking_data.status room = booking.room new_status = None if status_value: @@ -723,6 +725,29 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends room.status = RoomStatus.available except ValueError: raise HTTPException(status_code=400, detail='Invalid status') + + # Update other fields from schema if provided + if booking_data.check_in_date is not None: + if 'T' in booking_data.check_in_date or 'Z' in booking_data.check_in_date or '+' in booking_data.check_in_date: + booking.check_in_date = datetime.fromisoformat(booking_data.check_in_date.replace('Z', '+00:00')) + else: + booking.check_in_date = datetime.strptime(booking_data.check_in_date, '%Y-%m-%d') + + if booking_data.check_out_date is not None: + if 'T' in booking_data.check_out_date or 'Z' in booking_data.check_out_date or '+' in booking_data.check_out_date: + booking.check_out_date = datetime.fromisoformat(booking_data.check_out_date.replace('Z', '+00:00')) + else: + booking.check_out_date = datetime.strptime(booking_data.check_out_date, '%Y-%m-%d') + + if booking_data.guest_count is not None: + booking.num_guests = booking_data.guest_count + + if booking_data.notes is not None: + booking.special_requests = booking_data.notes + + if booking_data.total_price is not None: + booking.total_price = booking_data.total_price + db.commit() # Send booking confirmation notification if status changed to confirmed @@ -834,10 +859,11 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends import logging import traceback logger = logging.getLogger(__name__) - logger.error(f'Error updating booking {id}: {str(e)}') + request_id = getattr(current_user, 'request_id', None) if hasattr(current_user, 'request_id') else None + logger.error(f'Error updating booking {id}: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True) logger.error(traceback.format_exc()) db.rollback() - raise HTTPException(status_code=500, detail=f'Failed to update booking: {str(e)}') + raise HTTPException(status_code=500, detail='An error occurred while updating booking') @router.get('/check/{booking_number}') async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)): diff --git a/Backend/src/routes/chat_routes.py b/Backend/src/routes/chat_routes.py index 57dd4433..e4b33a87 100644 --- a/Backend/src/routes/chat_routes.py +++ b/Backend/src/routes/chat_routes.py @@ -5,10 +5,13 @@ from typing import List, Optional from datetime import datetime import json from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, get_current_user_optional from ..models.user import User from ..models.chat import Chat, ChatMessage, ChatStatus from ..models.role import Role + +logger = get_logger(__name__) router = APIRouter(prefix='/chat', tags=['chat']) class ConnectionManager: @@ -41,7 +44,7 @@ class ConnectionManager: try: await websocket.send_json(message) except Exception as e: - print(f'Error sending message: {e}') + logger.error(f'Error sending message: {str(e)}', exc_info=True) async def broadcast_to_chat(self, message: dict, chat_id: int): if chat_id in self.active_connections: @@ -50,7 +53,7 @@ class ConnectionManager: try: await connection.send_json(message) except Exception as e: - print(f'Error broadcasting to connection: {e}') + logger.error(f'Error broadcasting to connection: {str(e)}', exc_info=True, extra={'chat_id': chat_id}) disconnected.append(connection) for conn in disconnected: self.active_connections[chat_id].remove(conn) @@ -61,7 +64,7 @@ class ConnectionManager: try: await websocket.send_json({'type': 'new_chat', 'data': chat_data}) except Exception as e: - print(f'Error notifying staff {user_id}: {e}') + logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id}) disconnected.append(user_id) for user_id in disconnected: del self.staff_connections[user_id] @@ -74,7 +77,7 @@ class ConnectionManager: try: await websocket.send_json(notification_data) except Exception as e: - print(f'Error notifying staff {user_id}: {e}') + logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id, 'chat_id': chat_id}) disconnected.append(user_id) for user_id in disconnected: del self.staff_connections[user_id] @@ -296,16 +299,14 @@ async def websocket_staff_notifications(websocket: WebSocket): finally: db.close() except Exception as e: - print(f'WebSocket token verification error: {e}') - import traceback - traceback.print_exc() + logger.error(f'WebSocket token verification error: {str(e)}', exc_info=True) await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}') return manager.connect_staff(current_user.id, websocket) try: await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}}) except Exception as e: - print(f'Error sending initial message: {e}') + logger.error(f'Error sending initial message: {str(e)}', exc_info=True, extra={'user_id': current_user.id}) while True: try: data = await websocket.receive_text() @@ -316,17 +317,15 @@ async def websocket_staff_notifications(websocket: WebSocket): except json.JSONDecodeError: await websocket.send_json({'type': 'pong', 'data': 'pong'}) except WebSocketDisconnect: - print('WebSocket disconnected normally') + logger.info('WebSocket disconnected normally', extra={'user_id': current_user.id}) break except Exception as e: - print(f'WebSocket receive error: {e}') + logger.error(f'WebSocket receive error: {str(e)}', exc_info=True, extra={'user_id': current_user.id}) break except WebSocketDisconnect: - print('WebSocket disconnected') + logger.info('WebSocket disconnected') except Exception as e: - print(f'WebSocket error: {e}') - import traceback - traceback.print_exc() + logger.error(f'WebSocket error: {str(e)}', exc_info=True) finally: if current_user: try: diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py index e03448d4..a4c64057 100644 --- a/Backend/src/routes/contact_routes.py +++ b/Backend/src/routes/contact_routes.py @@ -8,6 +8,7 @@ from ..models.user import User from ..models.role import Role from ..models.system_settings import SystemSettings from ..utils.mailer import send_email +from ..utils.html_sanitizer import sanitize_text_for_html logger = logging.getLogger(__name__) router = APIRouter(prefix='/contact', tags=['contact']) diff --git a/Backend/src/routes/invoice_routes.py b/Backend/src/routes/invoice_routes.py index 5e108db7..2300a5e1 100644 --- a/Backend/src/routes/invoice_routes.py +++ b/Backend/src/routes/invoice_routes.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.orm import Session from typing import Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.invoice import Invoice, InvoiceStatus @@ -10,15 +11,25 @@ from ..models.booking import Booking from ..services.invoice_service import InvoiceService from ..utils.role_helpers import can_access_all_invoices, can_create_invoices from ..utils.response_helpers import success_response +from ..utils.request_helpers import get_request_id +from ..schemas.invoice import ( + CreateInvoiceRequest, + UpdateInvoiceRequest, + MarkInvoicePaidRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix='/invoices', tags=['invoices']) @router.get('/') -async def get_invoices(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: user_id = None if can_access_all_invoices(current_user, db) else current_user.id result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit) return success_response(data=result) except Exception as e: + db.rollback() + logger.error(f'Error fetching invoices: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{id}') @@ -33,64 +44,96 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/') -async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: if not can_create_invoices(current_user, db): raise HTTPException(status_code=403, detail='Forbidden') - booking_id = invoice_data.get('booking_id') - if not booking_id: - raise HTTPException(status_code=400, detail='booking_id is required') - try: - booking_id = int(booking_id) - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail='booking_id must be a valid integer') + booking_id = invoice_data.booking_id booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') - invoice_kwargs = {'company_name': invoice_data.get('company_name'), 'company_address': invoice_data.get('company_address'), 'company_phone': invoice_data.get('company_phone'), 'company_email': invoice_data.get('company_email'), 'company_tax_id': invoice_data.get('company_tax_id'), 'company_logo_url': invoice_data.get('company_logo_url'), 'customer_tax_id': invoice_data.get('customer_tax_id'), 'notes': invoice_data.get('notes'), 'terms_and_conditions': invoice_data.get('terms_and_conditions'), 'payment_instructions': invoice_data.get('payment_instructions')} + invoice_kwargs = { + 'company_name': invoice_data.company_name, + 'company_address': invoice_data.company_address, + 'company_phone': invoice_data.company_phone, + 'company_email': invoice_data.company_email, + 'company_tax_id': invoice_data.company_tax_id, + 'company_logo_url': invoice_data.company_logo_url, + 'customer_tax_id': invoice_data.customer_tax_id, + 'notes': invoice_data.notes, + 'terms_and_conditions': invoice_data.terms_and_conditions, + 'payment_instructions': invoice_data.payment_instructions + } invoice_notes = invoice_kwargs.get('notes', '') if booking.promotion_code: promotion_note = f'Promotion Code: {booking.promotion_code}' invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note invoice_kwargs['notes'] = invoice_notes - invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs) + request_id = get_request_id(request) + invoice = InvoiceService.create_invoice_from_booking( + booking_id=booking_id, + db=db, + created_by_id=current_user.id, + tax_rate=invoice_data.tax_rate, + discount_amount=invoice_data.discount_amount, + due_days=invoice_data.due_days, + request_id=request_id, + **invoice_kwargs + ) return success_response(data={'invoice': invoice}, message='Invoice created successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error creating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error creating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}') -async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): try: invoice = db.query(Invoice).filter(Invoice.id == id).first() if not invoice: raise HTTPException(status_code=404, detail='Invoice not found') - updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data) + request_id = get_request_id(request) + invoice_dict = invoice_data.model_dump(exclude_unset=True) + updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, request_id=request_id, **invoice_dict) return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error updating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error updating invoice: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/{id}/mark-paid') -async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): try: - amount = payment_data.get('amount') - updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id) + request_id = get_request_id(request) + amount = payment_data.amount + updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id, request_id=request_id) return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully') except HTTPException: raise except ValueError as e: + db.rollback() + logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + db.rollback() + logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}') @@ -121,4 +164,6 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/page_content_routes.py b/Backend/src/routes/page_content_routes.py index 90136046..01a3fa73 100644 --- a/Backend/src/routes/page_content_routes.py +++ b/Backend/src/routes/page_content_routes.py @@ -12,6 +12,8 @@ from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.page_content import PageContent, PageType +from ..schemas.page_content import PageContentUpdateRequest +from ..utils.html_sanitizer import sanitize_html logger = get_logger(__name__) router = APIRouter(prefix='/page-content', tags=['page-content']) @@ -25,6 +27,8 @@ async def get_all_page_contents(db: Session=Depends(get_db)): result.append(content_dict) return {'status': 'success', 'data': {'page_contents': result}} except Exception as e: + db.rollback() + logger.error(f'Error fetching page contents: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page contents: {str(e)}') def get_base_url(request: Request) -> str: @@ -58,11 +62,15 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(... ext = Path(image.filename).suffix or '.jpg' filename = f'page-content-{uuid.uuid4()}{ext}' file_path = upload_dir / filename + # Use comprehensive file validation (magic bytes + size) + from ..config.settings import settings + from ..utils.file_validation import validate_uploaded_image + max_size = settings.MAX_UPLOAD_SIZE + + # Validate file completely (MIME type, size, magic bytes, integrity) + content = await validate_uploaded_image(image, max_size) + async with aiofiles.open(file_path, 'wb') as f: - content = await image.read() - if not content: - logger.error('Empty file uploaded') - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes') image_url = f'/uploads/page-content/{filename}' @@ -86,12 +94,46 @@ async def get_page_content(page_type: PageType, db: Session=Depends(get_db)): content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: + db.rollback() + logger.error(f'Error fetching page content: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page content: {str(e)}') -@router.post('/{page_type}') -async def create_or_update_page_content(page_type: PageType, title: Optional[str]=None, subtitle: Optional[str]=None, description: Optional[str]=None, content: Optional[str]=None, meta_title: Optional[str]=None, meta_description: Optional[str]=None, meta_keywords: Optional[str]=None, og_title: Optional[str]=None, og_description: Optional[str]=None, og_image: Optional[str]=None, canonical_url: Optional[str]=None, contact_info: Optional[str]=None, map_url: Optional[str]=None, social_links: Optional[str]=None, footer_links: Optional[str]=None, badges: Optional[str]=None, hero_title: Optional[str]=None, hero_subtitle: Optional[str]=None, hero_image: Optional[str]=None, story_content: Optional[str]=None, values: Optional[str]=None, features: Optional[str]=None, about_hero_image: Optional[str]=None, mission: Optional[str]=None, vision: Optional[str]=None, team: Optional[str]=None, timeline: Optional[str]=None, achievements: Optional[str]=None, is_active: bool=True, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +@router.post('/{page_type}', dependencies=[Depends(authorize_roles('admin'))]) +async def create_or_update_page_content( + page_type: PageType, + title: Optional[str] = None, + subtitle: Optional[str] = None, + description: Optional[str] = None, + content: Optional[str] = None, + meta_title: Optional[str] = None, + meta_description: Optional[str] = None, + meta_keywords: Optional[str] = None, + og_title: Optional[str] = None, + og_description: Optional[str] = None, + og_image: Optional[str] = None, + canonical_url: Optional[str] = None, + contact_info: Optional[str] = None, + map_url: Optional[str] = None, + social_links: Optional[str] = None, + footer_links: Optional[str] = None, + badges: Optional[str] = None, + hero_title: Optional[str] = None, + hero_subtitle: Optional[str] = None, + hero_image: Optional[str] = None, + story_content: Optional[str] = None, + values: Optional[str] = None, + features: Optional[str] = None, + about_hero_image: Optional[str] = None, + mission: Optional[str] = None, + vision: Optional[str] = None, + team: Optional[str] = None, + timeline: Optional[str] = None, + achievements: Optional[str] = None, + is_active: bool = True, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): try: - authorize_roles(current_user, ['admin']) if contact_info: try: json.loads(contact_info) @@ -125,13 +167,14 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() if existing_content: if title is not None: - existing_content.title = title + existing_content.title = sanitize_html(title) if subtitle is not None: - existing_content.subtitle = subtitle + existing_content.subtitle = sanitize_html(subtitle) if description is not None: - existing_content.description = description + existing_content.description = sanitize_html(description) if content is not None: - existing_content.content = content + # Sanitize HTML content to prevent XSS + existing_content.content = sanitize_html(content) if meta_title is not None: existing_content.meta_title = meta_title if meta_description is not None: @@ -157,29 +200,30 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str if badges is not None: existing_content.badges = badges if hero_title is not None: - existing_content.hero_title = hero_title + existing_content.hero_title = sanitize_html(hero_title) if hero_subtitle is not None: - existing_content.hero_subtitle = hero_subtitle + existing_content.hero_subtitle = sanitize_html(hero_subtitle) if hero_image is not None: existing_content.hero_image = hero_image if story_content is not None: - existing_content.story_content = story_content + # Sanitize HTML content to prevent XSS + existing_content.story_content = sanitize_html(story_content) if values is not None: - existing_content.values = values + existing_content.values = sanitize_html(values) if features is not None: - existing_content.features = features + existing_content.features = sanitize_html(features) if about_hero_image is not None: existing_content.about_hero_image = about_hero_image if mission is not None: - existing_content.mission = mission + existing_content.mission = sanitize_html(mission) if vision is not None: - existing_content.vision = vision + existing_content.vision = sanitize_html(vision) if team is not None: - existing_content.team = team + existing_content.team = sanitize_html(team) if timeline is not None: - existing_content.timeline = timeline + existing_content.timeline = sanitize_html(timeline) if achievements is not None: - existing_content.achievements = achievements + existing_content.achievements = sanitize_html(achievements) if is_active is not None: existing_content.is_active = is_active existing_content.updated_at = datetime.utcnow() @@ -187,7 +231,39 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str db.refresh(existing_content) return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None}}} else: - new_content = PageContent(page_type=page_type, title=title, subtitle=subtitle, description=description, content=content, meta_title=meta_title, meta_description=meta_description, meta_keywords=meta_keywords, og_title=og_title, og_description=og_description, og_image=og_image, canonical_url=canonical_url, contact_info=contact_info, map_url=map_url, social_links=social_links, footer_links=footer_links, badges=badges, hero_title=hero_title, hero_subtitle=hero_subtitle, hero_image=hero_image, story_content=story_content, values=values, features=features, about_hero_image=about_hero_image, mission=mission, vision=vision, team=team, timeline=timeline, achievements=achievements, is_active=is_active) + # Sanitize all HTML content fields before creating new content + new_content = PageContent( + page_type=page_type, + title=sanitize_html(title) if title else None, + subtitle=sanitize_html(subtitle) if subtitle else None, + description=sanitize_html(description) if description else None, + content=sanitize_html(content) if content else None, + meta_title=meta_title, + meta_description=meta_description, + meta_keywords=meta_keywords, + og_title=og_title, + og_description=og_description, + og_image=og_image, + canonical_url=canonical_url, + contact_info=contact_info, + map_url=map_url, + social_links=social_links, + footer_links=footer_links, + badges=badges, + hero_title=sanitize_html(hero_title) if hero_title else None, + hero_subtitle=sanitize_html(hero_subtitle) if hero_subtitle else None, + hero_image=hero_image, + story_content=sanitize_html(story_content) if story_content else None, + values=sanitize_html(values) if values else None, + features=sanitize_html(features) if features else None, + about_hero_image=about_hero_image, + mission=sanitize_html(mission) if mission else None, + vision=sanitize_html(vision) if vision else None, + team=sanitize_html(team) if team else None, + timeline=sanitize_html(timeline) if timeline else None, + achievements=sanitize_html(achievements) if achievements else None, + is_active=is_active + ) db.add(new_content) db.commit() db.refresh(new_content) @@ -199,22 +275,22 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error saving page content: {str(e)}') @router.put('/{page_type}') -async def update_page_content(page_type: PageType, page_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_page_content(page_type: PageType, page_data: PageContentUpdateRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: authorize_roles(current_user, ['admin']) existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() if not existing_content: existing_content = PageContent(page_type=page_type, is_active=True) db.add(existing_content) - for key, value in page_data.items(): + + # Convert Pydantic model to dict, excluding None values + update_dict = page_data.model_dump(exclude_unset=True, exclude_none=False) + + for key, value in update_dict.items(): if hasattr(existing_content, key): + # Convert dict/list to JSON string for JSON fields if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None: - if isinstance(value, str): - try: - json.loads(value) - except json.JSONDecodeError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid JSON in {key}') - elif isinstance(value, (dict, list)): + if isinstance(value, (dict, list)): value = json.dumps(value) if value is not None: setattr(existing_content, key, value) @@ -227,4 +303,5 @@ async def update_page_content(page_type: PageType, page_data: dict, current_user raise except Exception as e: db.rollback() + logger.error(f'Error updating page content: {str(e)}', exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error updating page content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index daa28189..8099a888 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -5,6 +5,7 @@ from datetime import datetime import os from ..config.database import get_db from ..config.settings import settings +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus @@ -18,6 +19,10 @@ from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService from ..services.borica_service import BoricaService from ..services.loyalty_service import LoyaltyService +from ..services.audit_service import audit_service +from ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest + +logger = get_logger(__name__) router = APIRouter(prefix='/payments', tags=['payments']) async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): @@ -141,7 +146,11 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error in get_payments_by_booking_id: {str(e)}', extra={'booking_id': booking_id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching payments') @router.get('/{id}') async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): @@ -159,23 +168,40 @@ async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + db.rollback() + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error in get_payment_by_id: {str(e)}', extra={'payment_id': id}, exc_info=True) + raise HTTPException(status_code=500, detail='An error occurred while fetching payment') @router.post('/') -async def create_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_payment( + request: Request, + payment_data: CreatePaymentRequest, + current_user: User=Depends(get_current_user), + db: Session=Depends(get_db) +): + """Create a payment with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - booking_id = payment_data.get('booking_id') - amount = float(payment_data.get('amount', 0)) - payment_method = payment_data.get('payment_method', 'cash') - payment_type = payment_data.get('payment_type', 'full') + booking_id = payment_data.booking_id + amount = payment_data.amount + payment_method = payment_data.payment_method + payment_type = payment_data.payment_type + mark_as_paid = payment_data.mark_as_paid + notes = payment_data.notes + booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail='Booking not found') from ..utils.role_helpers import is_admin if not is_admin(current_user, db) and booking.user_id != current_user.id: raise HTTPException(status_code=403, detail='Forbidden') - payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if payment_data.get('mark_as_paid') else None, notes=payment_data.get('notes')) - if payment_data.get('mark_as_paid'): + payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes) + if mark_as_paid: payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() db.add(payment) @@ -228,20 +254,67 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr import logging logger = logging.getLogger(__name__) logger.error(f'Failed to send payment confirmation email: {e}') + + # Log payment transaction + await audit_service.log_action( + db=db, + action='payment_created', + resource_type='payment', + user_id=current_user.id, + resource_id=payment.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'booking_id': booking_id, + 'amount': float(amount), + 'payment_method': payment_method, + 'payment_type': payment_type, + 'payment_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), + 'transaction_id': payment.transaction_id + }, + status='success' + ) + return success_response(data={'payment': payment}, message='Payment created successfully') except HTTPException: raise except Exception as e: db.rollback() + # Log failed payment creation + await audit_service.log_action( + db=db, + action='payment_creation_failed', + resource_type='payment', + user_id=current_user.id if current_user else None, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'booking_id': payment_data.booking_id, 'amount': payment_data.amount}, + status='failed', + error_message=str(e) + ) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))]) -async def update_payment_status(id: int, status_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): +async def update_payment_status( + request: Request, + id: int, + status_data: UpdatePaymentStatusRequest, + current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), + db: Session=Depends(get_db) +): + """Update payment status with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: payment = db.query(Payment).filter(Payment.id == id).first() if not payment: raise HTTPException(status_code=404, detail='Payment not found') - status_value = status_data.get('status') + status_value = status_data.status + notes = status_data.notes old_status = payment.payment_status if status_value: try: @@ -273,13 +346,35 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') except ValueError: raise HTTPException(status_code=400, detail='Invalid payment status') - if status_data.get('transaction_id'): - payment.transaction_id = status_data['transaction_id'] - if status_data.get('mark_as_paid'): - payment.payment_status = PaymentStatus.completed - payment.payment_date = datetime.utcnow() + + # Update notes if provided + if notes: + existing_notes = payment.notes or '' + payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes db.commit() db.refresh(payment) + + # Log payment status update (admin action) + await audit_service.log_action( + db=db, + action='payment_status_updated', + resource_type='payment', + user_id=current_user.id, + resource_id=payment.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'payment_id': id, + 'old_status': old_status.value if hasattr(old_status, 'value') else str(old_status), + 'new_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status), + 'booking_id': payment.booking_id, + 'amount': float(payment.amount) if payment.amount else 0.0, + 'notes': notes + }, + status='success' + ) + if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: # Send payment receipt notification try: @@ -318,7 +413,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D payment.booking.status = BookingStatus.confirmed db.commit() except Exception as e: - print(f'Failed to send payment confirmation email: {e}') + logger.error(f'Failed to send payment confirmation email: {str(e)}', exc_info=True, extra={'payment_id': payment.id if hasattr(payment, 'id') else None}) return success_response(data={'payment': payment}, message='Payment status updated successfully') except HTTPException: raise @@ -327,7 +422,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D raise HTTPException(status_code=500, detail=str(e)) @router.post('/stripe/create-intent') -async def create_stripe_payment_intent(intent_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from ..services.stripe_service import get_stripe_secret_key secret_key = get_stripe_secret_key(db) @@ -335,14 +430,12 @@ async def create_stripe_payment_intent(intent_data: dict, current_user: User=Dep secret_key = settings.STRIPE_SECRET_KEY if not secret_key: raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.') - booking_id = intent_data.get('booking_id') - amount = float(intent_data.get('amount', 0)) - currency = intent_data.get('currency', 'usd') + booking_id = intent_data.booking_id + amount = intent_data.amount + currency = intent_data.currency or 'usd' import logging logger = logging.getLogger(__name__) logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}') - if not booking_id or amount <= 0: - raise HTTPException(status_code=400, detail='booking_id and amount are required') if amount > 999999.99: logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.") diff --git a/Backend/src/routes/promotion_routes.py b/Backend/src/routes/promotion_routes.py index a921e165..e4fd5bba 100644 --- a/Backend/src/routes/promotion_routes.py +++ b/Backend/src/routes/promotion_routes.py @@ -4,9 +4,17 @@ from sqlalchemy import or_ from typing import Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.promotion import Promotion, DiscountType +from ..schemas.promotion import ( + ValidatePromotionRequest, + CreatePromotionRequest, + UpdatePromotionRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix='/promotions', tags=['promotions']) @router.get('/') @@ -32,6 +40,8 @@ async def get_promotions(search: Optional[str]=Query(None), status_filter: Optio result.append(promo_dict) return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + db.rollback() + logger.error(f'Error fetching promotions: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{code}') @@ -45,13 +55,15 @@ async def get_promotion_by_code(code: str, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching promotion by code: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/validate') -async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)): +async def validate_promotion(validation_data: ValidatePromotionRequest, db: Session=Depends(get_db)): try: - code = validation_data.get('code') - booking_amount = float(validation_data.get('booking_value') or validation_data.get('booking_amount', 0)) + code = validation_data.code + booking_amount = float(validation_data.booking_value or validation_data.booking_amount or 0) promotion = db.query(Promotion).filter(Promotion.code == code).first() if not promotion: raise HTTPException(status_code=404, detail='Promotion code not found') @@ -72,20 +84,33 @@ async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)) except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error validating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/', dependencies=[Depends(authorize_roles('admin'))]) -async def create_promotion(promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def create_promotion(promotion_data: CreatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - code = promotion_data.get('code') + code = promotion_data.code existing = db.query(Promotion).filter(Promotion.code == code).first() if existing: raise HTTPException(status_code=400, detail='Promotion code already exists') - discount_type = promotion_data.get('discount_type') - discount_value = float(promotion_data.get('discount_value', 0)) - if discount_type == 'percentage' and discount_value > 100: - raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') - promotion = Promotion(code=code, name=promotion_data.get('name'), description=promotion_data.get('description'), discount_type=DiscountType(discount_type), discount_value=discount_value, min_booking_amount=float(promotion_data['min_booking_amount']) if promotion_data.get('min_booking_amount') else None, max_discount_amount=float(promotion_data['max_discount_amount']) if promotion_data.get('max_discount_amount') else None, start_date=datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data.get('start_date') else None, end_date=datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data.get('end_date') else None, usage_limit=promotion_data.get('usage_limit'), used_count=0, is_active=promotion_data.get('status') == 'active' if promotion_data.get('status') else True) + discount_type = promotion_data.discount_type + discount_value = promotion_data.discount_value + promotion = Promotion( + code=code, + name=promotion_data.name, + description=promotion_data.description, + discount_type=DiscountType(discount_type), + discount_value=discount_value, + min_booking_amount=promotion_data.min_booking_amount, + max_discount_amount=promotion_data.max_discount_amount, + start_date=datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None, + end_date=datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None, + usage_limit=promotion_data.usage_limit, + used_count=0, + is_active=promotion_data.status == 'active' if promotion_data.status else True + ) db.add(promotion) db.commit() db.refresh(promotion) @@ -94,47 +119,46 @@ async def create_promotion(promotion_data: dict, current_user: User=Depends(auth raise except Exception as e: db.rollback() + logger.error(f'Error creating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) -async def update_promotion(id: int, promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def update_promotion(id: int, promotion_data: UpdatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: promotion = db.query(Promotion).filter(Promotion.id == id).first() if not promotion: raise HTTPException(status_code=404, detail='Promotion not found') - code = promotion_data.get('code') + code = promotion_data.code if code and code != promotion.code: existing = db.query(Promotion).filter(Promotion.code == code, Promotion.id != id).first() if existing: raise HTTPException(status_code=400, detail='Promotion code already exists') - discount_type = promotion_data.get('discount_type', promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) - discount_value = promotion_data.get('discount_value') - if discount_value is not None: - discount_value = float(discount_value) - if discount_type == 'percentage' and discount_value > 100: - raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') - if 'code' in promotion_data: - promotion.code = promotion_data['code'] - if 'name' in promotion_data: - promotion.name = promotion_data['name'] - if 'description' in promotion_data: - promotion.description = promotion_data['description'] - if 'discount_type' in promotion_data: - promotion.discount_type = DiscountType(promotion_data['discount_type']) - if 'discount_value' in promotion_data: - promotion.discount_value = discount_value - if 'min_booking_amount' in promotion_data: - promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None - if 'max_discount_amount' in promotion_data: - promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None - if 'start_date' in promotion_data: - promotion.start_date = datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data['start_date'] else None - if 'end_date' in promotion_data: - promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None - if 'usage_limit' in promotion_data: - promotion.usage_limit = promotion_data['usage_limit'] - if 'status' in promotion_data: - promotion.is_active = promotion_data['status'] == 'active' + discount_type = promotion_data.discount_type or (promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) + discount_value = promotion_data.discount_value + if discount_value is not None and discount_type == 'percentage' and discount_value > 100: + raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') + if promotion_data.code is not None: + promotion.code = promotion_data.code + if promotion_data.name is not None: + promotion.name = promotion_data.name + if promotion_data.description is not None: + promotion.description = promotion_data.description + if promotion_data.discount_type is not None: + promotion.discount_type = DiscountType(promotion_data.discount_type) + if promotion_data.discount_value is not None: + promotion.discount_value = promotion_data.discount_value + if promotion_data.min_booking_amount is not None: + promotion.min_booking_amount = promotion_data.min_booking_amount + if promotion_data.max_discount_amount is not None: + promotion.max_discount_amount = promotion_data.max_discount_amount + if promotion_data.start_date is not None: + promotion.start_date = datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None + if promotion_data.end_date is not None: + promotion.end_date = datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None + if promotion_data.usage_limit is not None: + promotion.usage_limit = promotion_data.usage_limit + if promotion_data.status is not None: + promotion.is_active = promotion_data.status == 'active' db.commit() db.refresh(promotion) return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}} @@ -142,6 +166,7 @@ async def update_promotion(id: int, promotion_data: dict, current_user: User=Dep raise except Exception as e: db.rollback() + logger.error(f'Error updating promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @@ -157,4 +182,5 @@ async def delete_promotion(id: int, current_user: User=Depends(authorize_roles(' raise except Exception as e: db.rollback() + logger.error(f'Error deleting promotion: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/review_routes.py b/Backend/src/routes/review_routes.py index 95c1ee51..dcc9bc74 100644 --- a/Backend/src/routes/review_routes.py +++ b/Backend/src/routes/review_routes.py @@ -1,25 +1,51 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query +from ..utils.response_helpers import success_response from sqlalchemy.orm import Session from typing import Optional from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.review import Review, ReviewStatus from ..models.room import Room +from ..schemas.review import CreateReviewRequest + +logger = get_logger(__name__) router = APIRouter(prefix='/reviews', tags=['reviews']) @router.get('/room/{room_id}') -async def get_room_reviews(room_id: int, db: Session=Depends(get_db)): +async def get_room_reviews( + room_id: int, + page: int = Query(1, ge=1), + limit: int = Query(10, ge=1, le=100), + db: Session = Depends(get_db) +): try: - reviews = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all() + query = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved) + total = query.count() + offset = (page - 1) * limit + reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all() result = [] for review in reviews: review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email} result.append(review_dict) - return {'status': 'success', 'data': {'reviews': result}} + return { + 'status': 'success', + 'data': { + 'reviews': result, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'totalPages': (total + limit - 1) // limit + } + } + } except Exception as e: + db.rollback() + logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @@ -44,14 +70,16 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status result.append(review_dict) return {'status': 'success', 'data': {'reviews': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + db.rollback() + logger.error(f'Error fetching all reviews: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post('/') -async def create_review(review_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def create_review(review_data: CreateReviewRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - room_id = review_data.get('room_id') - rating = review_data.get('rating') - comment = review_data.get('comment') + room_id = review_data.room_id + rating = review_data.rating + comment = review_data.comment room = db.query(Room).filter(Room.id == room_id).first() if not room: raise HTTPException(status_code=404, detail='Room not found') @@ -67,6 +95,7 @@ async def create_review(review_data: dict, current_user: User=Depends(get_curren raise except Exception as e: db.rollback() + logger.error(f'Error creating review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) @@ -83,6 +112,7 @@ async def approve_review(id: int, current_user: User=Depends(authorize_roles('ad raise except Exception as e: db.rollback() + logger.error(f'Error approving review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) @@ -99,6 +129,7 @@ async def reject_review(id: int, current_user: User=Depends(authorize_roles('adm raise except Exception as e: db.rollback() + logger.error(f'Error rejecting review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @@ -114,4 +145,5 @@ async def delete_review(id: int, current_user: User=Depends(authorize_roles('adm raise except Exception as e: db.rollback() + logger.error(f'Error deleting review: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/room_routes.py b/Backend/src/routes/room_routes.py index c891029f..890e6e59 100644 --- a/Backend/src/routes/room_routes.py +++ b/Backend/src/routes/room_routes.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func from typing import List, Optional from datetime import datetime from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.room import Room, RoomStatus @@ -14,6 +15,8 @@ from ..services.room_service import get_rooms_with_ratings, get_amenities_list, import os import aiofiles from pathlib import Path + +logger = get_logger(__name__) router = APIRouter(prefix='/rooms', tags=['rooms']) @router.get('/') @@ -54,6 +57,7 @@ async def get_rooms(request: Request, type: Optional[str]=Query(None), minPrice: rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url) return {'status': 'success', 'data': {'rooms': rooms_with_ratings, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: + logger.error(f'Error fetching rooms: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/amenities') @@ -62,6 +66,7 @@ async def get_amenities(db: Session=Depends(get_db)): amenities = await get_amenities_list(db) return {'status': 'success', 'data': {'amenities': amenities}} except Exception as e: + logger.error(f'Error fetching amenities: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/available') @@ -159,6 +164,7 @@ async def search_available_rooms(request: Request, from_date: str=Query(..., ali except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + logger.error(f'Error searching available rooms: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get('/id/{id}') @@ -364,6 +370,8 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error uploading room images: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) @router.delete('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))]) @@ -421,6 +429,7 @@ async def get_room_booked_dates(id: int, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + logger.error(f'Error fetching booked dates: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) @router.get('/{id}/reviews') @@ -441,4 +450,5 @@ async def get_room_reviews_route(id: int, db: Session=Depends(get_db)): except HTTPException: raise except Exception as e: + logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True, extra={'room_id': id}) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/service_booking_routes.py b/Backend/src/routes/service_booking_routes.py index 937ddcf2..52785be5 100644 --- a/Backend/src/routes/service_booking_routes.py +++ b/Backend/src/routes/service_booking_routes.py @@ -5,6 +5,7 @@ from datetime import datetime import random from ..config.database import get_db +from ..config.logging_config import get_logger from ..middleware.auth import get_current_user from ..models.user import User from ..utils.role_helpers import is_admin @@ -19,6 +20,13 @@ from ..models.service_booking import ( ) from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key from ..config.settings import settings +from ..schemas.service_booking import ( + CreateServiceBookingRequest, + CreateServicePaymentIntentRequest, + ConfirmServicePaymentRequest +) + +logger = get_logger(__name__) router = APIRouter(prefix="/service-bookings", tags=["service-bookings"]) @@ -30,14 +38,14 @@ def generate_service_booking_number() -> str: @router.post("/") async def create_service_booking( - booking_data: dict, + booking_data: CreateServiceBookingRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): try: - services = booking_data.get("services", []) - total_amount = float(booking_data.get("total_amount", 0)) - notes = booking_data.get("notes") + services = booking_data.services + total_amount = booking_data.total_amount + notes = booking_data.notes if not services or len(services) == 0: raise HTTPException(status_code=400, detail="At least one service is required") @@ -50,8 +58,8 @@ async def create_service_booking( service_items_data = [] for service_item in services: - service_id = service_item.get("service_id") - quantity = service_item.get("quantity", 1) + service_id = service_item.service_id + quantity = service_item.quantity if not service_id: raise HTTPException(status_code=400, detail="Service ID is required for each item") @@ -197,6 +205,8 @@ async def get_my_service_bookings( "data": {"service_bookings": result} } except Exception as e: + db.rollback() + logger.error(f'Error fetching service bookings: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/{id}") @@ -249,12 +259,14 @@ async def get_service_booking_by_id( except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error fetching service booking by id: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/{id}/payment/stripe/create-intent") async def create_service_stripe_payment_intent( id: int, - intent_data: dict, + intent_data: CreateServicePaymentIntentRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -270,8 +282,8 @@ async def create_service_stripe_payment_intent( detail="Stripe is not configured. Please configure Stripe settings in Admin Panel." ) - amount = float(intent_data.get("amount", 0)) - currency = intent_data.get("currency", "usd") + amount = intent_data.amount + currency = intent_data.currency if amount <= 0: raise HTTPException(status_code=400, detail="Amount must be greater than 0") @@ -320,17 +332,19 @@ async def create_service_stripe_payment_intent( except HTTPException: raise except Exception as e: + db.rollback() + logger.error(f'Error creating service payment intent: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/{id}/payment/stripe/confirm") async def confirm_service_stripe_payment( id: int, - payment_data: dict, + payment_data: ConfirmServicePaymentRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): try: - payment_intent_id = payment_data.get("payment_intent_id") + payment_intent_id = payment_data.payment_intent_id if not payment_intent_id: raise HTTPException(status_code=400, detail="payment_intent_id is required") diff --git a/Backend/src/routes/user_routes.py b/Backend/src/routes/user_routes.py index a0300fd1..43b52cfe 100644 --- a/Backend/src/routes/user_routes.py +++ b/Backend/src/routes/user_routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional @@ -10,6 +10,8 @@ from ..models.role import Role from ..models.booking import Booking, BookingStatus from ..utils.role_helpers import can_manage_users from ..utils.response_helpers import success_response +from ..services.audit_service import audit_service +from ..schemas.user import CreateUserRequest, UpdateUserRequest router = APIRouter(prefix='/users', tags=['users']) @router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @@ -51,26 +53,53 @@ async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('ad raise HTTPException(status_code=500, detail=str(e)) @router.post('/', dependencies=[Depends(authorize_roles('admin'))]) -async def create_user(user_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): +async def create_user( + request: Request, + user_data: CreateUserRequest, + current_user: User=Depends(authorize_roles('admin')), + db: Session=Depends(get_db) +): + """Create a user with validated input using Pydantic schema.""" + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + request_id = getattr(request.state, 'request_id', None) + try: - email = user_data.get('email') - password = user_data.get('password') - full_name = user_data.get('full_name') - phone_number = user_data.get('phone_number') - role = user_data.get('role', 'customer') - status = user_data.get('status', 'active') - role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4} - role_id = role_map.get(role, 3) + email = user_data.email + password = user_data.password + full_name = user_data.full_name + phone_number = user_data.phone_number + role_id = user_data.role_id or 3 # Default to customer role existing = db.query(User).filter(User.email == email).first() if existing: raise HTTPException(status_code=400, detail='Email already exists') password_bytes = password.encode('utf-8') salt = bcrypt.gensalt() hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') - user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=status == 'active') + user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=True) db.add(user) db.commit() db.refresh(user) + + # Log admin action - user creation + await audit_service.log_action( + db=db, + action='admin_user_created', + resource_type='user', + user_id=current_user.id, + resource_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={ + 'created_user_email': user.email, + 'created_user_name': user.full_name, + 'role_id': user.role_id, + 'is_active': user.is_active + }, + status='success' + ) + user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} return success_response(data={'user': user_dict}, message='User created successfully') except HTTPException: @@ -80,37 +109,32 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role raise HTTPException(status_code=500, detail=str(e)) @router.put('/{id}') -async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): +async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + """Update a user with validated input using Pydantic schema.""" try: if not can_manage_users(current_user, db) and current_user.id != id: raise HTTPException(status_code=403, detail='Forbidden') user = db.query(User).filter(User.id == id).first() if not user: raise HTTPException(status_code=404, detail='User not found') - email = user_data.get('email') - if email and email != user.email: - existing = db.query(User).filter(User.email == email).first() + + # Check email uniqueness if being updated + if user_data.email and user_data.email != user.email: + existing = db.query(User).filter(User.email == user_data.email).first() if existing: raise HTTPException(status_code=400, detail='Email already exists') - role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4} - if 'full_name' in user_data: - user.full_name = user_data['full_name'] - if 'email' in user_data and can_manage_users(current_user, db): - user.email = user_data['email'] - if 'phone_number' in user_data: - user.phone = user_data['phone_number'] - if 'role' in user_data and can_manage_users(current_user, db): - user.role_id = role_map.get(user_data['role'], 3) - if 'status' in user_data and can_manage_users(current_user, db): - user.is_active = user_data['status'] == 'active' - if 'currency' in user_data: - currency = user_data['currency'] - if len(currency) == 3 and currency.isalpha(): - user.currency = currency.upper() - if 'password' in user_data: - password_bytes = user_data['password'].encode('utf-8') - salt = bcrypt.gensalt() - user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') + + # Update fields if provided + if user_data.full_name is not None: + user.full_name = user_data.full_name + if user_data.email is not None and can_manage_users(current_user, db): + user.email = user_data.email + if user_data.phone_number is not None: + user.phone = user_data.phone_number + if user_data.role_id is not None and can_manage_users(current_user, db): + user.role_id = user_data.role_id + if user_data.is_active is not None and can_manage_users(current_user, db): + user.is_active = user_data.is_active db.commit() db.refresh(user) user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} diff --git a/Backend/src/schemas/__pycache__/booking.cpython-312.pyc b/Backend/src/schemas/__pycache__/booking.cpython-312.pyc new file mode 100644 index 00000000..bd830792 Binary files /dev/null and b/Backend/src/schemas/__pycache__/booking.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/invoice.cpython-312.pyc b/Backend/src/schemas/__pycache__/invoice.cpython-312.pyc new file mode 100644 index 00000000..ac7b9c2f Binary files /dev/null and b/Backend/src/schemas/__pycache__/invoice.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/page_content.cpython-312.pyc b/Backend/src/schemas/__pycache__/page_content.cpython-312.pyc new file mode 100644 index 00000000..0a7d0bdb Binary files /dev/null and b/Backend/src/schemas/__pycache__/page_content.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/payment.cpython-312.pyc b/Backend/src/schemas/__pycache__/payment.cpython-312.pyc new file mode 100644 index 00000000..16f8ff66 Binary files /dev/null and b/Backend/src/schemas/__pycache__/payment.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/promotion.cpython-312.pyc b/Backend/src/schemas/__pycache__/promotion.cpython-312.pyc new file mode 100644 index 00000000..da73baaa Binary files /dev/null and b/Backend/src/schemas/__pycache__/promotion.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/review.cpython-312.pyc b/Backend/src/schemas/__pycache__/review.cpython-312.pyc new file mode 100644 index 00000000..fc2f5e49 Binary files /dev/null and b/Backend/src/schemas/__pycache__/review.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc b/Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc new file mode 100644 index 00000000..238851ce Binary files /dev/null and b/Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc differ diff --git a/Backend/src/schemas/__pycache__/user.cpython-312.pyc b/Backend/src/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..ea65b8ae Binary files /dev/null and b/Backend/src/schemas/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/schemas/booking.py b/Backend/src/schemas/booking.py new file mode 100644 index 00000000..b42c25e2 --- /dev/null +++ b/Backend/src/schemas/booking.py @@ -0,0 +1,167 @@ +""" +Pydantic schemas for booking-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator, model_validator +from typing import Optional, List +from datetime import datetime + + +class ServiceItemSchema(BaseModel): + """Schema for service items in a booking.""" + service_id: int = Field(..., gt=0, description="Service ID") + quantity: int = Field(1, gt=0, le=100, description="Quantity of service") + + +class InvoiceInfoSchema(BaseModel): + """Schema for invoice information.""" + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_tax_id: Optional[str] = Field(None, max_length=50) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + + +class CreateBookingRequest(BaseModel): + """Schema for creating a booking.""" + room_id: int = Field(..., gt=0, description="Room ID") + check_in_date: str = Field(..., description="Check-in date (YYYY-MM-DD or ISO format)") + check_out_date: str = Field(..., description="Check-out date (YYYY-MM-DD or ISO format)") + total_price: float = Field(..., gt=0, description="Total booking price") + guest_count: int = Field(1, gt=0, le=20, description="Number of guests") + notes: Optional[str] = Field(None, max_length=1000, description="Special requests/notes") + payment_method: str = Field("cash", description="Payment method (cash, stripe, paypal)") + promotion_code: Optional[str] = Field(None, max_length=50) + referral_code: Optional[str] = Field(None, max_length=50) + services: Optional[List[ServiceItemSchema]] = Field(default_factory=list) + invoice_info: Optional[InvoiceInfoSchema] = None + + @field_validator('check_in_date', 'check_out_date') + @classmethod + def validate_date_format(cls, v: str) -> str: + """Validate date format.""" + try: + # Try ISO format first + if 'T' in v or 'Z' in v or '+' in v: + datetime.fromisoformat(v.replace('Z', '+00:00')) + else: + # Try simple date format + datetime.strptime(v, '%Y-%m-%d') + return v + except (ValueError, TypeError): + raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.') + + @field_validator('payment_method') + @classmethod + def validate_payment_method(cls, v: str) -> str: + """Validate payment method.""" + allowed_methods = ['cash', 'stripe', 'paypal', 'borica'] + if v not in allowed_methods: + raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}') + return v + + @model_validator(mode='after') + def validate_dates(self): + """Validate that check-out is after check-in.""" + check_in = self.check_in_date + check_out = self.check_out_date + + if check_in and check_out: + try: + # Parse dates + if 'T' in check_in or 'Z' in check_in or '+' in check_in: + check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00')) + else: + check_in_dt = datetime.strptime(check_in, '%Y-%m-%d') + + if 'T' in check_out or 'Z' in check_out or '+' in check_out: + check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00')) + else: + check_out_dt = datetime.strptime(check_out, '%Y-%m-%d') + + if check_in_dt >= check_out_dt: + raise ValueError('Check-out date must be after check-in date') + except (ValueError, TypeError) as e: + if 'Check-out date' in str(e): + raise + raise ValueError('Invalid date format') + + return self + + model_config = { + "json_schema_extra": { + "example": { + "room_id": 1, + "check_in_date": "2024-12-25", + "check_out_date": "2024-12-30", + "total_price": 500.00, + "guest_count": 2, + "payment_method": "cash", + "notes": "Late check-in requested" + } + } + } + + +class UpdateBookingRequest(BaseModel): + """Schema for updating a booking.""" + status: Optional[str] = Field(None, description="Booking status") + check_in_date: Optional[str] = Field(None, description="Check-in date") + check_out_date: Optional[str] = Field(None, description="Check-out date") + guest_count: Optional[int] = Field(None, gt=0, le=20) + notes: Optional[str] = Field(None, max_length=1000) + total_price: Optional[float] = Field(None, gt=0) + + @field_validator('check_in_date', 'check_out_date') + @classmethod + def validate_date_format(cls, v: Optional[str]) -> Optional[str]: + """Validate date format if provided.""" + if v: + try: + if 'T' in v or 'Z' in v or '+' in v: + datetime.fromisoformat(v.replace('Z', '+00:00')) + else: + datetime.strptime(v, '%Y-%m-%d') + return v + except (ValueError, TypeError): + raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.') + return v + + @field_validator('status') + @classmethod + def validate_status(cls, v: Optional[str]) -> Optional[str]: + """Validate booking status.""" + if v: + allowed_statuses = ['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'] + if v not in allowed_statuses: + raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}') + return v + + @model_validator(mode='after') + def validate_dates(self): + """Validate dates if both are provided.""" + check_in = self.check_in_date + check_out = self.check_out_date + + if check_in and check_out: + try: + if 'T' in check_in or 'Z' in check_in or '+' in check_in: + check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00')) + else: + check_in_dt = datetime.strptime(check_in, '%Y-%m-%d') + + if 'T' in check_out or 'Z' in check_out or '+' in check_out: + check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00')) + else: + check_out_dt = datetime.strptime(check_out, '%Y-%m-%d') + + if check_in_dt >= check_out_dt: + raise ValueError('Check-out date must be after check-in date') + except (ValueError, TypeError) as e: + if 'Check-out date' in str(e): + raise + raise ValueError('Invalid date format') + + return self + diff --git a/Backend/src/schemas/invoice.py b/Backend/src/schemas/invoice.py new file mode 100644 index 00000000..eff7a436 --- /dev/null +++ b/Backend/src/schemas/invoice.py @@ -0,0 +1,77 @@ +""" +Pydantic schemas for invoice-related requests and responses. +""" +from pydantic import BaseModel, Field +from typing import Optional + + +class CreateInvoiceRequest(BaseModel): + """Schema for creating an invoice.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + tax_rate: float = Field(0.0, ge=0, le=100, description="Tax rate percentage") + discount_amount: float = Field(0.0, ge=0, description="Discount amount") + due_days: int = Field(30, ge=1, le=365, description="Number of days until due") + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_phone: Optional[str] = Field(None, max_length=50) + company_email: Optional[str] = Field(None, max_length=255) + company_tax_id: Optional[str] = Field(None, max_length=50) + company_logo_url: Optional[str] = Field(None, max_length=500) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + + model_config = { + "json_schema_extra": { + "example": { + "booking_id": 1, + "tax_rate": 10.0, + "discount_amount": 0.0, + "due_days": 30, + "company_name": "Hotel Name", + "company_address": "123 Main St", + "notes": "Payment due within 30 days" + } + } + } + + +class UpdateInvoiceRequest(BaseModel): + """Schema for updating an invoice.""" + company_name: Optional[str] = Field(None, max_length=200) + company_address: Optional[str] = Field(None, max_length=500) + company_phone: Optional[str] = Field(None, max_length=50) + company_email: Optional[str] = Field(None, max_length=255) + company_tax_id: Optional[str] = Field(None, max_length=50) + company_logo_url: Optional[str] = Field(None, max_length=500) + customer_tax_id: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=1000) + terms_and_conditions: Optional[str] = None + payment_instructions: Optional[str] = None + tax_rate: Optional[float] = Field(None, ge=0, le=100) + discount_amount: Optional[float] = Field(None, ge=0) + status: Optional[str] = None + + model_config = { + "json_schema_extra": { + "example": { + "notes": "Updated notes", + "status": "paid" + } + } + } + + +class MarkInvoicePaidRequest(BaseModel): + """Schema for marking an invoice as paid.""" + amount: Optional[float] = Field(None, gt=0, description="Payment amount (optional, defaults to full amount)") + + model_config = { + "json_schema_extra": { + "example": { + "amount": 500.00 + } + } + } + diff --git a/Backend/src/schemas/page_content.py b/Backend/src/schemas/page_content.py new file mode 100644 index 00000000..c8f46f29 --- /dev/null +++ b/Backend/src/schemas/page_content.py @@ -0,0 +1,110 @@ +""" +Pydantic schemas for page content-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Dict, Any, List, Union +import json + + +class PageContentUpdateRequest(BaseModel): + """Schema for updating page content.""" + title: Optional[str] = Field(None, max_length=500) + subtitle: Optional[str] = Field(None, max_length=1000) + description: Optional[str] = Field(None, max_length=5000) + content: Optional[str] = None + meta_title: Optional[str] = Field(None, max_length=200) + meta_description: Optional[str] = Field(None, max_length=500) + meta_keywords: Optional[str] = Field(None, max_length=500) + og_title: Optional[str] = Field(None, max_length=200) + og_description: Optional[str] = Field(None, max_length=500) + og_image: Optional[str] = Field(None, max_length=1000) + canonical_url: Optional[str] = Field(None, max_length=1000) + contact_info: Optional[Union[str, Dict[str, Any]]] = None + map_url: Optional[str] = Field(None, max_length=1000) + social_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None + footer_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None + badges: Optional[Union[str, List[Dict[str, Any]]]] = None + hero_title: Optional[str] = Field(None, max_length=500) + hero_subtitle: Optional[str] = Field(None, max_length=1000) + hero_image: Optional[str] = Field(None, max_length=1000) + story_content: Optional[str] = None + values: Optional[Union[str, List[Dict[str, Any]]]] = None + features: Optional[Union[str, List[Dict[str, Any]]]] = None + about_hero_image: Optional[str] = Field(None, max_length=1000) + mission: Optional[str] = Field(None, max_length=2000) + vision: Optional[str] = Field(None, max_length=2000) + team: Optional[Union[str, List[Dict[str, Any]]]] = None + timeline: Optional[Union[str, List[Dict[str, Any]]]] = None + achievements: Optional[Union[str, List[Dict[str, Any]]]] = None + amenities_section_title: Optional[str] = Field(None, max_length=500) + amenities_section_subtitle: Optional[str] = Field(None, max_length=1000) + amenities: Optional[Union[str, List[Dict[str, Any]]]] = None + testimonials_section_title: Optional[str] = Field(None, max_length=500) + testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000) + testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None + gallery_section_title: Optional[str] = Field(None, max_length=500) + gallery_section_subtitle: Optional[str] = Field(None, max_length=1000) + gallery_images: Optional[Union[str, List[str]]] = None + luxury_section_title: Optional[str] = Field(None, max_length=500) + luxury_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_section_image: Optional[str] = Field(None, max_length=1000) + luxury_features: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_gallery_section_title: Optional[str] = Field(None, max_length=500) + luxury_gallery_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_gallery: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_testimonials_section_title: Optional[str] = Field(None, max_length=500) + luxury_testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None + about_preview_title: Optional[str] = Field(None, max_length=500) + about_preview_subtitle: Optional[str] = Field(None, max_length=1000) + about_preview_content: Optional[str] = None + about_preview_image: Optional[str] = Field(None, max_length=1000) + stats: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_services_section_title: Optional[str] = Field(None, max_length=500) + luxury_services_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_services: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_experiences_section_title: Optional[str] = Field(None, max_length=500) + luxury_experiences_section_subtitle: Optional[str] = Field(None, max_length=1000) + luxury_experiences: Optional[Union[str, List[Dict[str, Any]]]] = None + awards_section_title: Optional[str] = Field(None, max_length=500) + awards_section_subtitle: Optional[str] = Field(None, max_length=1000) + awards: Optional[Union[str, List[Dict[str, Any]]]] = None + cta_title: Optional[str] = Field(None, max_length=500) + cta_subtitle: Optional[str] = Field(None, max_length=1000) + cta_button_text: Optional[str] = Field(None, max_length=200) + cta_button_link: Optional[str] = Field(None, max_length=1000) + cta_image: Optional[str] = Field(None, max_length=1000) + partners_section_title: Optional[str] = Field(None, max_length=500) + partners_section_subtitle: Optional[str] = Field(None, max_length=1000) + partners: Optional[Union[str, List[Dict[str, Any]]]] = None + copyright_text: Optional[str] = Field(None, max_length=500) + is_active: Optional[bool] = True + + @field_validator('contact_info', 'social_links', 'footer_links', 'badges', 'values', + 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', + 'luxury_features', 'luxury_gallery', 'luxury_testimonials', + 'luxury_services', 'luxury_experiences', 'awards', 'partners', + 'team', 'timeline', 'achievements', mode='before') + @classmethod + def validate_json_fields(cls, v): + """Validate and parse JSON string fields.""" + if v is None: + return None + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + raise ValueError(f'Invalid JSON format: {v}') + return v + + model_config = { + "json_schema_extra": { + "example": { + "title": "Welcome to Our Hotel", + "subtitle": "Experience luxury like never before", + "description": "A beautiful hotel description", + "is_active": True + } + } + } + diff --git a/Backend/src/schemas/payment.py b/Backend/src/schemas/payment.py new file mode 100644 index 00000000..b7051e0f --- /dev/null +++ b/Backend/src/schemas/payment.py @@ -0,0 +1,55 @@ +""" +Pydantic schemas for payment-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional + + +class CreatePaymentRequest(BaseModel): + """Schema for creating a payment.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + amount: float = Field(..., gt=0, le=999999.99, description="Payment amount") + payment_method: str = Field(..., description="Payment method") + payment_type: str = Field("full", description="Payment type (full, deposit)") + mark_as_paid: Optional[bool] = Field(False, description="Mark payment as completed immediately") + notes: Optional[str] = Field(None, max_length=1000, description="Payment notes") + + @field_validator('payment_method') + @classmethod + def validate_payment_method(cls, v: str) -> str: + """Validate payment method.""" + allowed_methods = ['cash', 'stripe', 'paypal', 'borica'] + if v not in allowed_methods: + raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}') + return v + + +class UpdatePaymentStatusRequest(BaseModel): + """Schema for updating payment status.""" + status: str = Field(..., description="New payment status") + notes: Optional[str] = Field(None, max_length=1000, description="Status change notes") + + @field_validator('status') + @classmethod + def validate_status(cls, v: str) -> str: + """Validate payment status.""" + allowed_statuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled'] + if v not in allowed_statuses: + raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}') + return v + + +class CreateStripePaymentIntentRequest(BaseModel): + """Schema for creating a Stripe payment intent.""" + booking_id: int = Field(..., gt=0, description="Booking ID") + amount: float = Field(..., gt=0, le=999999.99, description="Payment amount") + currency: Optional[str] = Field("usd", description="Currency code") + + @field_validator('amount') + @classmethod + def validate_amount(cls, v: float) -> float: + """Validate amount doesn't exceed Stripe limit.""" + if v > 999999.99: + raise ValueError(f"Amount ${v:,.2f} exceeds Stripe's maximum of $999,999.99") + return v + diff --git a/Backend/src/schemas/promotion.py b/Backend/src/schemas/promotion.py new file mode 100644 index 00000000..6156cead --- /dev/null +++ b/Backend/src/schemas/promotion.py @@ -0,0 +1,122 @@ +""" +Pydantic schemas for promotion-related requests and responses. +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from datetime import datetime + + +class ValidatePromotionRequest(BaseModel): + """Schema for validating a promotion code.""" + code: str = Field(..., min_length=1, max_length=50, description="Promotion code") + booking_value: Optional[float] = Field(None, ge=0, description="Booking value/amount") + booking_amount: Optional[float] = Field(None, ge=0, description="Booking amount (alias for booking_value)") + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + """Validate promotion code format.""" + if not v or not v.strip(): + raise ValueError("Promotion code cannot be empty") + return v.strip().upper() + + model_config = { + "json_schema_extra": { + "example": { + "code": "SUMMER2024", + "booking_value": 500.00 + } + } + } + + +class CreatePromotionRequest(BaseModel): + """Schema for creating a promotion.""" + code: str = Field(..., min_length=1, max_length=50, description="Promotion code") + name: str = Field(..., min_length=1, max_length=200, description="Promotion name") + description: Optional[str] = Field(None, max_length=1000) + discount_type: str = Field(..., description="Discount type: 'percentage' or 'fixed'") + discount_value: float = Field(..., gt=0, description="Discount value") + min_booking_amount: Optional[float] = Field(None, ge=0) + max_discount_amount: Optional[float] = Field(None, ge=0) + start_date: Optional[str] = Field(None, description="Start date (ISO format)") + end_date: Optional[str] = Field(None, description="End date (ISO format)") + usage_limit: Optional[int] = Field(None, ge=1) + status: Optional[str] = Field("active", description="Status: 'active' or 'inactive'") + + @field_validator('discount_type') + @classmethod + def validate_discount_type(cls, v: str) -> str: + """Validate discount type.""" + if v not in ['percentage', 'fixed']: + raise ValueError("Discount type must be 'percentage' or 'fixed'") + return v + + @field_validator('discount_value') + @classmethod + def validate_discount_value(cls, v: float, info) -> float: + """Validate discount value based on type.""" + if 'discount_type' in info.data and info.data['discount_type'] == 'percentage': + if v > 100: + raise ValueError("Percentage discount cannot exceed 100%") + return v + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + """Validate promotion code format.""" + if not v or not v.strip(): + raise ValueError("Promotion code cannot be empty") + return v.strip().upper() + + model_config = { + "json_schema_extra": { + "example": { + "code": "SUMMER2024", + "name": "Summer Sale", + "description": "20% off all bookings", + "discount_type": "percentage", + "discount_value": 20.0, + "min_booking_amount": 100.0, + "max_discount_amount": 500.0, + "start_date": "2024-06-01T00:00:00Z", + "end_date": "2024-08-31T23:59:59Z", + "usage_limit": 100, + "status": "active" + } + } + } + + +class UpdatePromotionRequest(BaseModel): + """Schema for updating a promotion.""" + code: Optional[str] = Field(None, min_length=1, max_length=50) + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = Field(None, max_length=1000) + discount_type: Optional[str] = None + discount_value: Optional[float] = Field(None, gt=0) + min_booking_amount: Optional[float] = Field(None, ge=0) + max_discount_amount: Optional[float] = Field(None, ge=0) + start_date: Optional[str] = None + end_date: Optional[str] = None + usage_limit: Optional[int] = Field(None, ge=1) + status: Optional[str] = None + + @field_validator('discount_type') + @classmethod + def validate_discount_type(cls, v: Optional[str]) -> Optional[str]: + """Validate discount type if provided.""" + if v and v not in ['percentage', 'fixed']: + raise ValueError("Discount type must be 'percentage' or 'fixed'") + return v + + model_config = { + "json_schema_extra": { + "example": { + "name": "Updated Summer Sale", + "discount_value": 25.0, + "status": "active" + } + } + } + diff --git a/Backend/src/schemas/review.py b/Backend/src/schemas/review.py new file mode 100644 index 00000000..3604d123 --- /dev/null +++ b/Backend/src/schemas/review.py @@ -0,0 +1,23 @@ +""" +Pydantic schemas for review-related requests and responses. +""" +from pydantic import BaseModel, Field +from typing import Optional + + +class CreateReviewRequest(BaseModel): + """Schema for creating a review.""" + room_id: int = Field(..., gt=0, description="Room ID") + rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5") + comment: Optional[str] = Field(None, max_length=2000, description="Review comment") + + model_config = { + "json_schema_extra": { + "example": { + "room_id": 1, + "rating": 5, + "comment": "Great room, excellent service!" + } + } + } + diff --git a/Backend/src/schemas/service_booking.py b/Backend/src/schemas/service_booking.py new file mode 100644 index 00000000..e960f6e2 --- /dev/null +++ b/Backend/src/schemas/service_booking.py @@ -0,0 +1,63 @@ +""" +Pydantic schemas for service booking-related requests and responses. +""" +from pydantic import BaseModel, Field, model_validator +from typing import Optional, List +from ..schemas.booking import ServiceItemSchema + + +class CreateServiceBookingRequest(BaseModel): + """Schema for creating a service booking.""" + services: List[ServiceItemSchema] = Field(..., min_length=1, description="List of services to book") + total_amount: float = Field(..., gt=0, description="Total amount for the booking") + notes: Optional[str] = Field(None, max_length=1000, description="Additional notes") + + @model_validator(mode='after') + def validate_total_amount(self): + """Validate that total amount matches calculated total.""" + calculated = sum(item.quantity * 1.0 for item in self.services) # Will be validated against service prices in route + # Note: We can't validate exact amount here without service prices, but we validate structure + if self.total_amount <= 0: + raise ValueError("Total amount must be greater than 0") + return self + + model_config = { + "json_schema_extra": { + "example": { + "services": [ + {"service_id": 1, "quantity": 2} + ], + "total_amount": 100.00, + "notes": "Special request" + } + } + } + + +class CreateServicePaymentIntentRequest(BaseModel): + """Schema for creating a Stripe payment intent for service booking.""" + amount: float = Field(..., gt=0, description="Payment amount") + currency: str = Field("usd", max_length=3, description="Currency code") + + model_config = { + "json_schema_extra": { + "example": { + "amount": 100.00, + "currency": "usd" + } + } + } + + +class ConfirmServicePaymentRequest(BaseModel): + """Schema for confirming a service payment.""" + payment_intent_id: str = Field(..., min_length=1, description="Stripe payment intent ID") + + model_config = { + "json_schema_extra": { + "example": { + "payment_intent_id": "pi_1234567890" + } + } + } + diff --git a/Backend/src/schemas/user.py b/Backend/src/schemas/user.py new file mode 100644 index 00000000..b0b55f2e --- /dev/null +++ b/Backend/src/schemas/user.py @@ -0,0 +1,38 @@ +""" +Pydantic schemas for user-related requests and responses. +""" +from pydantic import BaseModel, Field, EmailStr, field_validator +from typing import Optional + + +class CreateUserRequest(BaseModel): + """Schema for creating a user.""" + full_name: str = Field(..., min_length=2, max_length=100, description="Full name") + email: EmailStr = Field(..., description="Email address") + password: str = Field(..., min_length=8, description="Password") + phone_number: Optional[str] = Field(None, max_length=20, description="Phone number") + role_id: Optional[int] = Field(None, gt=0, description="Role ID") + + @field_validator('password') + @classmethod + def validate_password(cls, v: str) -> str: + """Validate password strength.""" + if len(v) < 8: + raise ValueError('Password must be at least 8 characters') + if not any(c.isupper() for c in v): + raise ValueError('Password must contain at least one uppercase letter') + if not any(c.islower() for c in v): + raise ValueError('Password must contain at least one lowercase letter') + if not any(c.isdigit() for c in v): + raise ValueError('Password must contain at least one number') + return v + + +class UpdateUserRequest(BaseModel): + """Schema for updating a user.""" + full_name: Optional[str] = Field(None, min_length=2, max_length=100) + email: Optional[EmailStr] = None + phone_number: Optional[str] = Field(None, max_length=20) + role_id: Optional[int] = Field(None, gt=0) + is_active: Optional[bool] = None + diff --git a/Backend/src/services/__pycache__/audit_service.cpython-312.pyc b/Backend/src/services/__pycache__/audit_service.cpython-312.pyc new file mode 100644 index 00000000..eb68629c Binary files /dev/null and b/Backend/src/services/__pycache__/audit_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/auth_service.cpython-312.pyc b/Backend/src/services/__pycache__/auth_service.cpython-312.pyc index febaf1cd..bc3035a0 100644 Binary files a/Backend/src/services/__pycache__/auth_service.cpython-312.pyc and b/Backend/src/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index eae712e1..9dc912e0 100644 Binary files a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc and b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc b/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc index 18ebf6f0..c61e52cb 100644 Binary files a/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc and b/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc index 2f24cb64..801f8a30 100644 Binary files a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc and b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/task_service.cpython-312.pyc b/Backend/src/services/__pycache__/task_service.cpython-312.pyc index 0cb4f518..35f4ffb7 100644 Binary files a/Backend/src/services/__pycache__/task_service.cpython-312.pyc and b/Backend/src/services/__pycache__/task_service.cpython-312.pyc differ diff --git a/Backend/src/services/auth_service.py b/Backend/src/services/auth_service.py index 52dfd1d4..4887d748 100644 --- a/Backend/src/services/auth_service.py +++ b/Backend/src/services/auth_service.py @@ -81,6 +81,12 @@ class AuthService: } async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict: + # Validate password strength + from ..utils.password_validation import validate_password_strength + is_valid, errors = validate_password_strength(password) + if not is_valid: + error_message = 'Password does not meet requirements: ' + '; '.join(errors) + raise ValueError(error_message) existing_user = db.query(User).filter(User.email == email).first() if existing_user: @@ -146,11 +152,39 @@ class AuthService: logger.warning(f"Login attempt for inactive user: {email}") raise ValueError("Account is disabled. Please contact support.") + # Check if account is locked (reset if lockout expired) + if user.locked_until: + if user.locked_until > datetime.utcnow(): + remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) + logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})") + raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).") + else: + # Lockout expired, reset it + user.locked_until = None + user.failed_login_attempts = 0 + db.commit() + user.role = db.query(Role).filter(Role.id == user.role_id).first() - if not self.verify_password(password, user.password): - logger.warning(f"Login attempt with invalid password for user: {email}") - raise ValueError("Invalid email or password") + password_valid = self.verify_password(password, user.password) + + # Handle failed login attempt + if not password_valid: + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + max_attempts = settings.MAX_LOGIN_ATTEMPTS + lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES + + # Lock account if max attempts reached + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) + logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}") + db.commit() + raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + else: + remaining_attempts = max_attempts - user.failed_login_attempts + logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)") + db.commit() + raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.") if user.mfa_enabled: if not mfa_token: @@ -164,7 +198,26 @@ class AuthService: from ..services.mfa_service import mfa_service is_backup_code = len(mfa_token) == 8 if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code): - raise ValueError("Invalid MFA token") + # Increment failed attempts on MFA failure + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + max_attempts = settings.MAX_LOGIN_ATTEMPTS + lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES + + if user.failed_login_attempts >= max_attempts: + user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) + logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}") + db.commit() + raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + else: + remaining_attempts = max_attempts - user.failed_login_attempts + db.commit() + raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.") + + # Reset failed login attempts and unlock account on successful login + if user.failed_login_attempts > 0 or user.locked_until: + user.failed_login_attempts = 0 + user.locked_until = None + db.commit() tokens = self.generate_tokens(user.id) @@ -272,6 +325,13 @@ class AuthService: raise ValueError("Current password is required to change password") if not self.verify_password(current_password, user.password): raise ValueError("Current password is incorrect") + + # Validate new password strength + from ..utils.password_validation import validate_password_strength + is_valid, errors = validate_password_strength(password) + if not is_valid: + error_message = 'New password does not meet requirements: ' + '; '.join(errors) + raise ValueError(error_message) user.password = self.hash_password(password) diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index 735cf69e..5119e961 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -6,6 +6,9 @@ from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus from ..models.booking import Booking from ..models.payment import Payment, PaymentStatus from ..models.user import User +from ..config.logging_config import get_logger + +logger = get_logger(__name__) def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: prefix = 'PRO' if is_proforma else 'INV' @@ -24,10 +27,12 @@ def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: class InvoiceService: @staticmethod - def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, **kwargs) -> Dict[str, Any]: + def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]: from sqlalchemy.orm import selectinload + logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id}) booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first() if not booking: + logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id}) raise ValueError('Booking not found') user = db.query(User).filter(User.id == booking.user_id).first() if not user: @@ -94,7 +99,7 @@ class InvoiceService: return InvoiceService.invoice_to_dict(invoice) @staticmethod - def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, **kwargs) -> Dict[str, Any]: + def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: raise ValueError('Invoice not found') @@ -121,7 +126,7 @@ class InvoiceService: return InvoiceService.invoice_to_dict(invoice) @staticmethod - def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None) -> Dict[str, Any]: + def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None, request_id: Optional[str]=None) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: raise ValueError('Invoice not found') diff --git a/Backend/src/services/paypal_service.py b/Backend/src/services/paypal_service.py index f494c60f..d3029750 100644 --- a/Backend/src/services/paypal_service.py +++ b/Backend/src/services/paypal_service.py @@ -4,13 +4,14 @@ from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, Orde from paypalcheckoutsdk.payments import CapturesRefundRequest from typing import Optional, Dict, Any from ..config.settings import settings +from ..config.logging_config import get_logger from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.booking import Booking, BookingStatus from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime import json -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def get_paypal_client_id(db: Session) -> Optional[str]: try: @@ -285,10 +286,7 @@ class PayPalService: db.rollback() raise except Exception as e: - import traceback - error_details = traceback.format_exc() error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' - print(f'Error in confirm_payment: {error_msg}') - print(f'Traceback: {error_details}') + logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'order_id': order_id, 'booking_id': booking_id}) db.rollback() raise ValueError(f'Error confirming payment: {error_msg}') \ No newline at end of file diff --git a/Backend/src/services/stripe_service.py b/Backend/src/services/stripe_service.py index a24cde4a..b298acd6 100644 --- a/Backend/src/services/stripe_service.py +++ b/Backend/src/services/stripe_service.py @@ -2,12 +2,13 @@ import logging import stripe from typing import Optional, Dict, Any from ..config.settings import settings +from ..config.logging_config import get_logger from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.booking import Booking, BookingStatus from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def get_stripe_secret_key(db: Session) -> Optional[str]: try: @@ -98,7 +99,7 @@ class StripeService: if not booking: raise ValueError('Booking not found') payment_status = intent_data.get('status') - print(f'Payment intent status: {payment_status}') + logger.info(f'Payment intent status: {payment_status}', extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id}) if payment_status not in ['succeeded', 'processing']: raise ValueError(f'Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.') payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first() @@ -207,21 +208,20 @@ class StripeService: try: return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None} except AttributeError as ae: - print(f'AttributeError accessing payment fields: {ae}') - print(f'Payment object: {payment}') - print(f'Payment payment_method: {(payment.payment_method if hasattr(payment, 'payment_method') else 'missing')}') - print(f'Payment payment_type: {(payment.payment_type if hasattr(payment, 'payment_type') else 'missing')}') - print(f'Payment payment_status: {(payment.payment_status if hasattr(payment, 'payment_status') else 'missing')}') + logger.error(f'AttributeError accessing payment fields: {ae}', exc_info=True, extra={ + 'payment_id': payment.id if hasattr(payment, 'id') else None, + 'booking_id': booking_id, + 'payment_method': payment.payment_method if hasattr(payment, 'payment_method') else 'missing', + 'payment_type': payment.payment_type if hasattr(payment, 'payment_type') else 'missing', + 'payment_status': payment.payment_status if hasattr(payment, 'payment_status') else 'missing' + }) raise except ValueError as e: db.rollback() raise except Exception as e: - import traceback - error_details = traceback.format_exc() error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' - print(f'Error in confirm_payment: {error_msg}') - print(f'Traceback: {error_details}') + logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id}) db.rollback() raise ValueError(f'Error confirming payment: {error_msg}') diff --git a/Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc b/Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc new file mode 100644 index 00000000..fed229df Binary files /dev/null and b/Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc differ diff --git a/Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc b/Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc new file mode 100644 index 00000000..a65270da Binary files /dev/null and b/Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc differ diff --git a/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc b/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc index be9648ee..7e9bf35f 100644 Binary files a/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc and b/Backend/src/utils/__pycache__/response_helpers.cpython-312.pyc differ diff --git a/Backend/src/utils/file_validation.py b/Backend/src/utils/file_validation.py new file mode 100644 index 00000000..e7fcc0db --- /dev/null +++ b/Backend/src/utils/file_validation.py @@ -0,0 +1,148 @@ +""" +File validation utilities for secure file uploads. +Validates file types using magic bytes (file signatures) to prevent spoofing. +""" +from PIL import Image +import io +from typing import Tuple, Optional +from fastapi import UploadFile, HTTPException, status + +# Magic bytes for common image formats +IMAGE_MAGIC_BYTES = { + b'\xFF\xD8\xFF': 'image/jpeg', + b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A': 'image/png', + b'GIF87a': 'image/gif', + b'GIF89a': 'image/gif', + b'RIFF': 'image/webp', # WebP files start with RIFF, need deeper check + b'\x00\x00\x01\x00': 'image/x-icon', + b'\x00\x00\x02\x00': 'image/x-icon', +} + +ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} + +def validate_image_file_signature(file_content: bytes, filename: str) -> Tuple[bool, str]: + """ + Validate file type using magic bytes (file signature). + This prevents MIME type spoofing attacks. + + Args: + file_content: The file content as bytes + filename: The filename (for extension checking) + + Returns: + Tuple of (is_valid, error_message) + """ + if not file_content: + return False, "File is empty" + + # Check magic bytes for image types + file_start = file_content[:16] # Check first 16 bytes + + detected_type = None + + # Check for JPEG + if file_content.startswith(b'\xFF\xD8\xFF'): + detected_type = 'image/jpeg' + # Check for PNG + elif file_content.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): + detected_type = 'image/png' + # Check for GIF + elif file_content.startswith(b'GIF87a') or file_content.startswith(b'GIF89a'): + detected_type = 'image/gif' + # Check for WebP (RIFF header with WEBP in bytes 8-11) + elif file_content.startswith(b'RIFF') and len(file_content) > 12: + if file_content[8:12] == b'WEBP': + detected_type = 'image/webp' + # Check for ICO + elif file_content.startswith(b'\x00\x00\x01\x00') or file_content.startswith(b'\x00\x00\x02\x00'): + detected_type = 'image/x-icon' + + # If magic bytes don't match known image types, try PIL verification + if not detected_type: + try: + # Try to open with PIL to verify it's a valid image + img = Image.open(io.BytesIO(file_content)) + img.verify() + + # Get format from PIL + img_format = img.format.lower() if img.format else None + if img_format == 'jpeg': + detected_type = 'image/jpeg' + elif img_format == 'png': + detected_type = 'image/png' + elif img_format == 'gif': + detected_type = 'image/gif' + elif img_format == 'webp': + detected_type = 'image/webp' + else: + return False, f"Unsupported image format: {img_format}" + except Exception: + return False, "File is not a valid image or is corrupted" + + # Verify detected type is in allowed list + if detected_type not in ALLOWED_IMAGE_TYPES and detected_type != 'image/x-icon': + return False, f"File type {detected_type} is not allowed. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}" + + return True, detected_type + + +async def validate_uploaded_image(file: UploadFile, max_size: int) -> bytes: + """ + Validate an uploaded image file completely. + + Args: + file: FastAPI UploadFile object + max_size: Maximum file size in bytes + + Returns: + File content as bytes + + Raises: + HTTPException if validation fails + """ + # Check MIME type first (quick check) + if not file.content_type or not file.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'File must be an image. Received MIME type: {file.content_type}' + ) + + # Read file content + content = await file.read() + + # Validate file size + if len(content) > max_size: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File size ({len(content)} bytes) exceeds maximum allowed size ({max_size} bytes / {max_size // 1024 // 1024}MB)' + ) + + # Validate file signature (magic bytes) + is_valid, result = validate_image_file_signature(content, file.filename or '') + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid file type: {result}. File signature validation failed. Please upload a valid image file.' + ) + + # Additional PIL validation to ensure image is not corrupted + try: + img = Image.open(io.BytesIO(content)) + # Verify image integrity + img.verify() + # Re-open for further processing (verify() closes the image) + img = Image.open(io.BytesIO(content)) + # Check image dimensions to prevent decompression bombs + if img.width > 10000 or img.height > 10000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Image dimensions too large. Maximum dimensions: 10000x10000 pixels' + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid or corrupted image file: {str(e)}' + ) + + return content + diff --git a/Backend/src/utils/html_sanitizer.py b/Backend/src/utils/html_sanitizer.py new file mode 100644 index 00000000..05ab3bdc --- /dev/null +++ b/Backend/src/utils/html_sanitizer.py @@ -0,0 +1,99 @@ +""" +HTML sanitization utilities for backend content storage. +Prevents XSS attacks by sanitizing HTML before storing in database. +""" +import bleach +from typing import Optional + +# Allowed HTML tags for rich content +ALLOWED_TAGS = [ + 'p', 'br', 'strong', 'em', 'u', 'b', 'i', 'span', 'div', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', + 'a', 'blockquote', 'pre', 'code', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'img', 'hr', 'section', 'article' +] + +# Allowed HTML attributes +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title', 'target', 'rel'], + 'img': ['src', 'alt', 'title', 'width', 'height', 'class'], + 'div': ['class', 'id', 'style'], + 'span': ['class', 'id', 'style'], + 'p': ['class', 'id', 'style'], + 'h1': ['class', 'id'], + 'h2': ['class', 'id'], + 'h3': ['class', 'id'], + 'h4': ['class', 'id'], + 'h5': ['class', 'id'], + 'h6': ['class', 'id'], + 'table': ['class', 'id'], + 'tr': ['class', 'id'], + 'th': ['class', 'id', 'colspan', 'rowspan'], + 'td': ['class', 'id', 'colspan', 'rowspan'], +} + +# Allowed URL schemes +ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'] + +def sanitize_html(html_content: Optional[str]) -> str: + """ + Sanitize HTML content to prevent XSS attacks. + + Args: + html_content: HTML string to sanitize (can be None) + + Returns: + Sanitized HTML string safe for storage + """ + if not html_content: + return '' + + # Clean HTML content + cleaned = bleach.clean( + html_content, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_SCHEMES, + strip=True, # Strip disallowed tags instead of escaping + strip_comments=True, # Remove HTML comments + ) + + # Additional link sanitization - ensure external links have rel="noopener" + if '' + elif 'noopener' not in tag and 'noreferrer' not in tag: + # Add to existing rel attribute + tag = tag.replace('rel="', 'rel="noopener noreferrer ') + tag = tag.replace("rel='", "rel='noopener noreferrer ") + return tag + return tag + + cleaned = re.sub(r']*>', add_rel, cleaned) + + return cleaned + +def sanitize_text_for_html(text: Optional[str]) -> str: + """ + Escape text content to be safely included in HTML. + Use this for plain text that should be displayed as-is. + + Args: + text: Plain text string to escape + + Returns: + HTML-escaped string + """ + if not text: + return '' + + return bleach.clean(text, tags=[], strip=True) + diff --git a/Backend/src/utils/password_validation.py b/Backend/src/utils/password_validation.py new file mode 100644 index 00000000..16104f65 --- /dev/null +++ b/Backend/src/utils/password_validation.py @@ -0,0 +1,59 @@ +""" +Password validation utilities for enforcing password strength requirements. +""" +import re +from typing import Tuple, List + +# Password strength requirements +MIN_PASSWORD_LENGTH = 8 +REQUIRE_UPPERCASE = True +REQUIRE_LOWERCASE = True +REQUIRE_NUMBER = True +REQUIRE_SPECIAL = True + +def validate_password_strength(password: str) -> Tuple[bool, List[str]]: + """ + Validate password meets strength requirements. + + Args: + password: The password to validate + + Returns: + Tuple of (is_valid, list_of_errors) + """ + errors = [] + + if not password: + return False, ['Password is required'] + + # Check minimum length + if len(password) < MIN_PASSWORD_LENGTH: + errors.append(f'Password must be at least {MIN_PASSWORD_LENGTH} characters long') + + # Check for uppercase letter + if REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): + errors.append('Password must contain at least one uppercase letter') + + # Check for lowercase letter + if REQUIRE_LOWERCASE and not re.search(r'[a-z]', password): + errors.append('Password must contain at least one lowercase letter') + + # Check for number + if REQUIRE_NUMBER and not re.search(r'\d', password): + errors.append('Password must contain at least one number') + + # Check for special character + if REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): + errors.append('Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)') + + # Check for common weak passwords + common_passwords = [ + 'password', '12345678', 'qwerty', 'abc123', 'password123', + 'admin', 'letmein', 'welcome', 'monkey', '1234567890' + ] + if password.lower() in common_passwords: + errors.append('Password is too common. Please choose a stronger password') + + is_valid = len(errors) == 0 + return is_valid, errors + diff --git a/Backend/src/utils/request_helpers.py b/Backend/src/utils/request_helpers.py new file mode 100644 index 00000000..9bc59565 --- /dev/null +++ b/Backend/src/utils/request_helpers.py @@ -0,0 +1,21 @@ +""" +Utility functions for request handling +""" +from typing import Optional +from fastapi import Request + + +def get_request_id(request: Optional[Request] = None) -> Optional[str]: + """ + Extract request_id from request state. + + Args: + request: FastAPI Request object + + Returns: + Request ID string or None + """ + if not request: + return None + return getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + diff --git a/Backend/src/utils/response_helpers.py b/Backend/src/utils/response_helpers.py index b7729c1f..957e493a 100644 --- a/Backend/src/utils/response_helpers.py +++ b/Backend/src/utils/response_helpers.py @@ -2,6 +2,7 @@ Utility functions for standardizing API responses """ from typing import Any, Dict, Optional +from fastapi import HTTPException, Request def success_response( data: Any = None, @@ -31,6 +32,7 @@ def success_response( def error_response( message: str, errors: Optional[list] = None, + request_id: Optional[str] = None, **kwargs ) -> Dict[str, Any]: """ @@ -45,7 +47,40 @@ def error_response( if errors: response['errors'] = errors + if request_id: + response['request_id'] = request_id + response.update(kwargs) return response + +def raise_http_exception( + status_code: int, + message: str, + errors: Optional[list] = None, + request: Optional[Request] = None, + **kwargs +) -> None: + """ + Raise an HTTPException with standardized error response format. + + Args: + status_code: HTTP status code + message: Error message + errors: Optional list of error details + request: Optional Request object to extract request_id + **kwargs: Additional fields to include in response + """ + request_id = None + if request: + request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None + + detail = error_response( + message=message, + errors=errors, + request_id=request_id, + **kwargs + ) + raise HTTPException(status_code=status_code, detail=detail) + diff --git a/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc index ad6169f3..41d4ad06 100644 Binary files a/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc and b/Backend/venv/lib/python3.12/site-packages/__pycache__/six.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/INSTALLER b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/LICENSE b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/LICENSE new file mode 100644 index 00000000..467c38e4 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2014-2017, Mozilla Foundation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/METADATA b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/METADATA new file mode 100644 index 00000000..86e7972a --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/METADATA @@ -0,0 +1,1247 @@ +Metadata-Version: 2.1 +Name: bleach +Version: 6.1.0 +Summary: An easy safelist-based HTML-sanitizing tool. +Home-page: https://github.com/mozilla/bleach +Maintainer: Will Kahn-Greene +Maintainer-email: willkg@mozilla.com +License: Apache Software License +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Web Environment +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.8 +Description-Content-Type: text/x-rst +License-File: LICENSE +Requires-Dist: six >=1.9.0 +Requires-Dist: webencodings +Provides-Extra: css +Requires-Dist: tinycss2 <1.3,>=1.1.0 ; extra == 'css' + +====== +Bleach +====== + +.. image:: https://github.com/mozilla/bleach/workflows/Test/badge.svg + :target: https://github.com/mozilla/bleach/actions?query=workflow%3ATest + +.. image:: https://github.com/mozilla/bleach/workflows/Lint/badge.svg + :target: https://github.com/mozilla/bleach/actions?query=workflow%3ALint + +.. image:: https://badge.fury.io/py/bleach.svg + :target: http://badge.fury.io/py/bleach + +**NOTE: 2023-01-23: Bleach is deprecated.** See issue: +``__ + +Bleach is an allowed-list-based HTML sanitizing library that escapes or strips +markup and attributes. + +Bleach can also linkify text safely, applying filters that Django's ``urlize`` +filter cannot, and optionally setting ``rel`` attributes, even on links already +in the text. + +Bleach is intended for sanitizing text from *untrusted* sources. If you find +yourself jumping through hoops to allow your site administrators to do lots of +things, you're probably outside the use cases. Either trust those users, or +don't. + +Because it relies on html5lib_, Bleach is as good as modern browsers at dealing +with weird, quirky HTML fragments. And *any* of Bleach's methods will fix +unbalanced or mis-nested tags. + +The version on GitHub_ is the most up-to-date and contains the latest bug +fixes. You can find full documentation on `ReadTheDocs`_. + +:Code: https://github.com/mozilla/bleach +:Documentation: https://bleach.readthedocs.io/ +:Issue tracker: https://github.com/mozilla/bleach/issues +:License: Apache License v2; see LICENSE file + + +Reporting Bugs +============== + +For regular bugs, please report them `in our issue tracker +`_. + +If you believe that you've found a security vulnerability, please `file a secure +bug report in our bug tracker +`_ +or send an email to *security AT mozilla DOT org*. + +For more information on security-related bug disclosure and the PGP key to use +for sending encrypted mail or to verify responses received from that address, +please read our wiki page at +``_. + + +Security +======== + +Bleach is a security-focused library. + +We have a responsible security vulnerability reporting process. Please use +that if you're reporting a security issue. + +Security issues are fixed in private. After we land such a fix, we'll do a +release. + +For every release, we mark security issues we've fixed in the ``CHANGES`` in +the **Security issues** section. We include any relevant CVE links. + + +Installing Bleach +================= + +Bleach is available on PyPI_, so you can install it with ``pip``:: + + $ pip install bleach + + +Upgrading Bleach +================ + +.. warning:: + + Before doing any upgrades, read through `Bleach Changes + `_ for backwards + incompatible changes, newer versions, etc. + + Bleach follows `semver 2`_ versioning. Vendored libraries will not + be changed in patch releases. + + +Basic use +========= + +The simplest way to use Bleach is: + +.. code-block:: python + + >>> import bleach + + >>> bleach.clean('an example') + u'an <script>evil()</script> example' + + >>> bleach.linkify('an http://example.com url') + u'an http://example.com url' + + +Code of Conduct +=============== + +This project and repository is governed by Mozilla's code of conduct and +etiquette guidelines. For more details please see the `CODE_OF_CONDUCT.md +`_ + + +.. _html5lib: https://github.com/html5lib/html5lib-python +.. _GitHub: https://github.com/mozilla/bleach +.. _ReadTheDocs: https://bleach.readthedocs.io/ +.. _PyPI: https://pypi.org/project/bleach/ +.. _semver 2: https://semver.org/ + + +Bleach changes +============== + +Version 6.1.0 (October 6th, 2023) +--------------------------------- + +**Backwards incompatible changes** + +* Dropped support for Python 3.7. (#709) + +**Security fixes** + +None + +**Bug fixes** + +* Add support for Python 3.12. (#710) +* Fix linkify with arrays in querystring (#436) +* Handle more cases with < followed by character data (#705) +* Fix entities inside a tags in linkification (#704) +* Update cap for tinycss2 to <1.3 (#702) +* Updated Sphinx requirement +* Add dependabot for github actions and update github actions + + +Version 6.0.0 (January 23rd, 2023) +---------------------------------- + +**Backwards incompatible changes** + +* ``bleach.clean``, ``bleach.sanitizer.Cleaner``, + ``bleach.html5lib_shim.BleachHTMLParser``: the ``tags`` and ``protocols`` + arguments were changed from lists to sets. + + Old pre-6.0.0: + + .. code-block:: python + + bleach.clean( + "some text", + tags=["a", "p", "img"], + # ^ ^ list + protocols=["http", "https"], + # ^ ^ list + ) + + + New 6.0.0 and later: + + .. code-block:: python + + bleach.clean( + "some text", + tags={"a", "p", "img"}, + # ^ ^ set + protocols={"http", "https"}, + # ^ ^ set + ) + +* ``bleach.linkify``, ``bleach.linkifier.Linker``: the ``skip_tags`` and + ``recognized_tags`` arguments were changed from lists to sets. + + Old pre-6.0.0: + + .. code-block:: python + + bleach.linkify( + "some text", + skip_tags=["pre"], + # ^ ^ list + ) + + linker = Linker( + skip_tags=["pre"], + # ^ ^ list + recognized_tags=html5lib_shim.HTML_TAGS + ["custom-element"], + # ^ ^ ^ list + # | + # | list concatenation + ) + + New 6.0.0 and later: + + .. code-block:: python + + bleach.linkify( + "some text", + skip_tags={"pre"}, + # ^ ^ set + ) + + linker = Linker( + skip_tags={"pre"}, + # ^ ^ set + recognized_tags=html5lib_shim.HTML_TAGS | {"custom-element"}, + # ^ ^ ^ set + # | + # | union operator + ) + +* ``bleach.sanitizer.BleachSanitizerFilter``: ``strip_allowed_elements`` is now + ``strip_allowed_tags``. We now use "tags" everywhere rather than a mishmash + of "tags" in some places and "elements" in others. + + +**Security fixes** + +None + + +**Bug fixes** + +* Add support for Python 3.11. (#675) + +* Fix API weirness in ``BleachSanitizerFilter``. (#649) + + We're using "tags" instead of "elements" everywhere--no more weird + overloading of "elements" anymore. + + Also, it no longer calls the superclass constructor. + +* Add warning when ``css_sanitizer`` isn't set, but the ``style`` + attribute is allowed. (#676) + +* Fix linkify handling of character entities. (#501) + +* Rework dev dependencies to use ``requirements-dev.txt`` and + ``requirements-flake8.txt`` instead of extras. + +* Fix project infrastructure to be tox-based so it's easier to have CI + run the same things we're running in development and with flake8 + in an isolated environment. + +* Update action versions in CI. + +* Switch to f-strings where possible. Make tests parametrized to be + easier to read/maintain. + + +Version 5.0.1 (June 27th, 2022) +------------------------------- + +**Security fixes** + +None + + +**Bug fixes** + +* Add missing comma to tinycss2 require. Thank you, @shadchin! + +* Add url parse tests based on wpt url tests. (#688) + +* Support scheme-less urls if "https" is in allow list. (#662) + +* Handle escaping ``<`` in edge cases where it doesn't start a tag. (#544) + +* Fix reference warnings in docs. (#660) + +* Correctly urlencode email address parts. Thank you, @larseggert! (#659) + + +Version 5.0.0 (April 7th, 2022) +------------------------------- + +**Backwards incompatible changes** + +* ``clean`` and ``linkify`` now preserve the order of HTML attributes. Thank + you, @askoretskly! (#566) + +* Drop support for Python 3.6. Thank you, @hugovk! (#629) + +* CSS sanitization in style tags is completely different now. If you're using + Bleach ``clean`` to sanitize css in style tags, you'll need to update your + code and you'll need to install the ``css`` extras:: + + pip install 'bleach[css]' + + See `the documentation on sanitizing CSS for how to do it + `_. (#633) + +**Security fixes** + +None + +**Bug fixes** + +* Rework dev dependencies. We no longer have + ``requirements-dev.in``/``requirements-dev.txt``. Instead, we're using + ``dev`` extras. + + See `development docs `_ + for more details. (#620) + +* Add newline when dropping block-level tags. Thank you, @jvanasco! (#369) + + +Version 4.1.0 (August 25th, 2021) +--------------------------------- + +**Features** + +* Python 3.9 support + +**Security fixes** + +None + +**Bug fixes** + +* Update sanitizer clean to use vendored 3.6.14 stdlib urllib.parse to + fix test failures on Python 3.9. (#536) + + +Version 4.0.0 (August 3rd, 2021) +-------------------------------- + +**Backwards incompatible changes** + +* Drop support for unsupported Python versions <3.6. (#520) + +**Security fixes** + +None + +**Features** + +* fix attribute name in the linkify docs (thanks @CheesyFeet!) + + +Version 3.3.1 (July 14th, 2021) +------------------------------- + +**Security fixes** + +None + +**Features** + +* add more tests for CVE-2021-23980 / GHSA-vv2x-vrpj-qqpq +* bump python version to 3.8 for tox doc, vendorverify, and lint targets +* update bug report template tag +* update vendorverify script to detect and fail when extra files are vendored +* update release process docs to check vendorverify passes locally + +**Bug fixes** + +* remove extra vendored django present in the v3.3.0 whl (#595) +* duplicate h1 header doc fix (thanks Nguyễn Gia Phong / @McSinyx!) + + +Version 3.3.0 (February 1st, 2021) +---------------------------------- + +**Backwards incompatible changes** + +* clean escapes HTML comments even when strip_comments=False + +**Security fixes** + +* Fix bug 1621692 / GHSA-m6xf-fq7q-8743. See the advisory for details. + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.2.3 (January 26th, 2021) +---------------------------------- + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* fix clean and linkify raising ValueErrors for certain inputs. Thank you @Google-Autofuzz. + + +Version 3.2.2 (January 20th, 2021) +---------------------------------- + +**Security fixes** + +None + +**Features** + +* Migrate CI to Github Actions. Thank you @hugovk. + +**Bug fixes** + +* fix linkify raising an IndexError on certain inputs. Thank you @Google-Autofuzz. + + +Version 3.2.1 (September 18th, 2020) +------------------------------------ + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* change linkifier to add rel="nofollow" as documented. Thank you @mitar. +* suppress html5lib sanitizer DeprecationWarnings (#557) + + +Version 3.2.0 (September 16th, 2020) +------------------------------------ + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* ``html5lib`` dependency to version 1.1.0. Thank you Sam Sneddon. +* update tests_website terminology. Thank you Thomas Grainger. + + +Version 3.1.5 (April 29th, 2020) +-------------------------------- + +**Security fixes** + +None + +**Features** + +None + +**Bug fixes** + +* replace missing ``setuptools`` dependency with ``packaging``. Thank you Benjamin Peterson. + + +Version 3.1.4 (March 24th, 2020) +-------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing style attributes could result in a + regular expression denial of service (ReDoS). + + Calls to ``bleach.clean`` with an allowed tag with an allowed + ``style`` attribute were vulnerable to ReDoS. For example, + ``bleach.clean(..., attributes={'a': ['style']})``. + + This issue was confirmed in Bleach versions v3.1.3, v3.1.2, v3.1.1, + v3.1.0, v3.0.0, v2.1.4, and v2.1.3. Earlier versions used a similar + regular expression and should be considered vulnerable too. + + Anyone using Bleach <=v3.1.3 is encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1623633 + +**Backwards incompatible changes** + +* Style attributes with dashes, or single or double quoted values are + cleaned instead of passed through. + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.3 (March 17th, 2020) +-------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* Drop support for Python 3.4. Thank you, @hugovk! + +* Drop deprecated ``setup.py test`` support. Thank you, @jdufresne! (#507) + +**Features** + +* Add support for Python 3.8. Thank you, @jdufresne! + +* Add support for PyPy 7. Thank you, @hugovk! + +* Add pypy3 testing to tox and travis. Thank you, @jdufresne! + +**Bug fixes** + +* Add relative link to code of conduct. (#442) + +* Fix typo: curren -> current in tests/test_clean.py Thank you, timgates42! (#504) + +* Fix handling of non-ascii style attributes. Thank you, @sekineh! (#426) + +* Simplify tox configuration. Thank you, @jdufresne! + +* Make documentation reproducible. Thank you, @lamby! + +* Fix typos in code comments. Thank you, @zborboa-g! + +* Fix exception value testing. Thank you, @mastizada! + +* Fix parser-tags NoneType exception. Thank you, @bope! + +* Improve TLD support in linkify. Thank you, @pc-coholic! + + +Version 3.1.2 (March 11th, 2020) +-------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing embedded MathML and SVG content + with RCDATA tags did not match browser behavior and could result in + a mutation XSS. + + Calls to ``bleach.clean`` with ``strip=False`` and ``math`` or + ``svg`` tags and one or more of the RCDATA tags ``script``, + ``noscript``, ``style``, ``noframes``, ``iframe``, ``noembed``, or + ``xmp`` in the allowed tags whitelist were vulnerable to a mutation + XSS. + + This security issue was confirmed in Bleach version v3.1.1. Earlier + versions are likely affected too. + + Anyone using Bleach <=v3.1.1 is encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1621692 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.1 (February 13th, 2020) +----------------------------------- + +**Security fixes** + +* ``bleach.clean`` behavior parsing ``noscript`` tags did not match + browser behavior. + + Calls to ``bleach.clean`` allowing ``noscript`` and one or more of + the raw text tags (``title``, ``textarea``, ``script``, ``style``, + ``noembed``, ``noframes``, ``iframe``, and ``xmp``) were vulnerable + to a mutation XSS. + + This security issue was confirmed in Bleach versions v2.1.4, v3.0.2, + and v3.1.0. Earlier versions are probably affected too. + + Anyone using Bleach <=v3.1.0 is highly encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1615315 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +None + + +Version 3.1.0 (January 9th, 2019) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +* Add ``recognized_tags`` argument to the linkify ``Linker`` class. This + fixes issues when linkifying on its own and having some tags get escaped. + It defaults to a list of HTML5 tags. Thank you, Chad Birch! (#409) + +**Bug fixes** + +* Add ``six>=1.9`` to requirements. Thank you, Dave Shawley (#416) + +* Fix cases where attribute names could have invalid characters in them. + (#419) + +* Fix problems with ``LinkifyFilter`` not being able to match links + across ``&``. (#422) + +* Fix ``InputStreamWithMemory`` when the ``BleachHTMLParser`` is + parsing ``meta`` tags. (#431) + +* Fix doctests. (#357) + + +Version 3.0.2 (October 11th, 2018) +---------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Merge ``Characters`` tokens after sanitizing them. This fixes issues in the + ``LinkifyFilter`` where it was only linkifying parts of urls. (#374) + + +Version 3.0.1 (October 9th, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +* Support Python 3.7. It supported Python 3.7 just fine, but we added 3.7 to + the list of Python environments we test so this is now officially supported. + (#377) + +**Bug fixes** + +* Fix ``list`` object has no attribute ``lower`` in ``clean``. (#398) +* Fix ``abbr`` getting escaped in ``linkify``. (#400) + + +Version 3.0.0 (October 3rd, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* A bunch of functions were moved from one module to another. + + These were moved from ``bleach.sanitizer`` to ``bleach.html5lib_shim``: + + * ``convert_entity`` + * ``convert_entities`` + * ``match_entity`` + * ``next_possible_entity`` + * ``BleachHTMLSerializer`` + * ``BleachHTMLTokenizer`` + * ``BleachHTMLParser`` + + These functions and classes weren't documented and aren't part of the + public API, but people read code and might be using them so we're + considering it an incompatible API change. + + If you're using them, you'll need to update your code. + +**Features** + +* Bleach no longer depends on html5lib. html5lib==1.0.1 is now vendored into + Bleach. You can remove it from your requirements file if none of your other + requirements require html5lib. + + This means Bleach will now work fine with other libraries that depend on + html5lib regardless of what version of html5lib they require. (#386) + +**Bug fixes** + +* Fixed tags getting added when using clean or linkify. This was a + long-standing regression from the Bleach 2.0 rewrite. (#280, #392) + +* Fixed ```` getting replaced with a string. Now it gets escaped or + stripped depending on whether it's in the allowed tags or not. (#279) + + +Version 2.1.4 (August 16th, 2018) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +* Dropped support for Python 3.3. (#328) + +**Features** + +None + +**Bug fixes** + +* Handle ambiguous ampersands in correctly. (#359) + + +Version 2.1.3 (March 5th, 2018) +------------------------------- + +**Security fixes** + +* Attributes that have URI values weren't properly sanitized if the + values contained character entities. Using character entities, it + was possible to construct a URI value with a scheme that was not + allowed that would slide through unsanitized. + + This security issue was introduced in Bleach 2.1. Anyone using + Bleach 2.1 is highly encouraged to upgrade. + + https://bugzilla.mozilla.org/show_bug.cgi?id=1442745 + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Fixed some other edge cases for attribute URI value sanitizing and + improved testing of this code. + + +Version 2.1.2 (December 7th, 2017) +---------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Support html5lib-python 1.0.1. (#337) + +* Add deprecation warning for supporting html5lib-python < 1.0. + +* Switch to semver. + + +Version 2.1.1 (October 2nd, 2017) +--------------------------------- + +**Security fixes** + +None + +**Backwards incompatible changes** + +None + +**Features** + +None + +**Bug fixes** + +* Fix ``setup.py`` opening files when ``LANG=``. (#324) + + +Version 2.1 (September 28th, 2017) +---------------------------------- + +**Security fixes** + +* Convert control characters (backspace particularly) to "?" preventing + malicious copy-and-paste situations. (#298) + + See ``_ for more details. + + This affects all previous versions of Bleach. Check the comments on that + issue for ways to alleviate the issue if you can't upgrade to Bleach 2.1. + + +**Backwards incompatible changes** + +* Redid versioning. ``bleach.VERSION`` is no longer available. Use the string + version at ``bleach.__version__`` and parse it with + ``pkg_resources.parse_version``. (#307) + +* clean, linkify: linkify and clean should only accept text types; thank you, + Janusz! (#292) + +* clean, linkify: accept only unicode or utf-8-encoded str (#176) + + +**Features** + + +**Bug fixes** + +* ``bleach.clean()`` no longer unescapes entities including ones that are missing + a ``;`` at the end which can happen in urls and other places. (#143) + +* linkify: fix http links inside of mailto links; thank you, sedrubal! (#300) + +* clarify security policy in docs (#303) + +* fix dependency specification for html5lib 1.0b8, 1.0b9, and 1.0b10; thank you, + Zoltán! (#268) + +* add Bleach vs. html5lib comparison to README; thank you, Stu Cox! (#278) + +* fix KeyError exceptions on tags without href attr; thank you, Alex Defsen! + (#273) + +* add test website and scripts to test ``bleach.clean()`` output in browser; + thank you, Greg Guthe! + + +Version 2.0 (March 8th, 2017) +----------------------------- + +**Security fixes** + +* None + + +**Backwards incompatible changes** + +* Removed support for Python 2.6. (#206) + +* Removed support for Python 3.2. (#224) + +* Bleach no longer supports html5lib < 0.99999999 (8 9s). + + This version is a rewrite to use the new sanitizing API since the old + one was dropped in html5lib 0.99999999 (8 9s). + + If you're using 0.9999999 (7 9s) upgrade to 0.99999999 (8 9s) or higher. + + If you're using 1.0b8 (equivalent to 0.9999999 (7 9s)), upgrade to 1.0b9 + (equivalent to 0.99999999 (8 9s)) or higher. + +* ``bleach.clean`` and friends were rewritten + + ``clean`` was reimplemented as an html5lib filter and happens at a different + step in the HTML parsing -> traversing -> serializing process. Because of + that, there are some differences in clean's output as compared with previous + versions. + + Amongst other things, this version will add end tags even if the tag in + question is to be escaped. + +* ``bleach.clean`` and friends attribute callables now take three arguments: + tag, attribute name and attribute value. Previously they only took attribute + name and attribute value. + + All attribute callables will need to be updated. + +* ``bleach.linkify`` was rewritten + + ``linkify`` was reimplemented as an html5lib Filter. As such, it no longer + accepts a ``tokenizer`` argument. + + The callback functions for adjusting link attributes now takes a namespaced + attribute. + + Previously you'd do something like this:: + + def check_protocol(attrs, is_new): + if not attrs.get('href', '').startswith('http:', 'https:')): + return None + return attrs + + Now it's more like this:: + + def check_protocol(attrs, is_new): + if not attrs.get((None, u'href'), u'').startswith(('http:', 'https:')): + # ^^^^^^^^^^^^^^^ + return None + return attrs + + Further, you need to make sure you're always using unicode values. If you + don't then html5lib will raise an assertion error that the value is not + unicode. + + All linkify filters will need to be updated. + +* ``bleach.linkify`` and friends had a ``skip_pre`` argument--that's been + replaced with a more general ``skip_tags`` argument. + + Before, you might do:: + + bleach.linkify(some_text, skip_pre=True) + + The equivalent with Bleach 2.0 is:: + + bleach.linkify(some_text, skip_tags=['pre']) + + You can skip other tags, too, like ``style`` or ``script`` or other places + where you don't want linkification happening. + + All uses of linkify that use ``skip_pre`` will need to be updated. + + +**Changes** + +* Supports Python 3.6. + +* Supports html5lib >= 0.99999999 (8 9s). + +* There's a ``bleach.sanitizer.Cleaner`` class that you can instantiate with your + favorite clean settings for easy reuse. + +* There's a ``bleach.linkifier.Linker`` class that you can instantiate with your + favorite linkify settings for easy reuse. + +* There's a ``bleach.linkifier.LinkifyFilter`` which is an htm5lib filter that + you can pass as a filter to ``bleach.sanitizer.Cleaner`` allowing you to clean + and linkify in one pass. + +* ``bleach.clean`` and friends can now take a callable as an attributes arg value. + +* Tons of bug fixes. + +* Cleaned up tests. + +* Documentation fixes. + + +Version 1.5 (November 4th, 2016) +-------------------------------- + +**Security fixes** + +* None + +**Backwards incompatible changes** + +* clean: The list of ``ALLOWED_PROTOCOLS`` now defaults to http, https and + mailto. + + Previously it was a long list of protocols something like ed2k, ftp, http, + https, irc, mailto, news, gopher, nntp, telnet, webcal, xmpp, callto, feed, + urn, aim, rsync, tag, ssh, sftp, rtsp, afs, data. (#149) + +**Changes** + +* clean: Added ``protocols`` to arguments list to let you override the list of + allowed protocols. Thank you, Andreas Malecki! (#149) + +* linkify: Fix a bug involving periods at the end of an email address. Thank you, + Lorenz Schori! (#219) + +* linkify: Fix linkification of non-ascii ports. Thank you Alexandre, Macabies! + (#207) + +* linkify: Fix linkify inappropriately removing node tails when dropping nodes. + (#132) + +* Fixed a test that failed periodically. (#161) + +* Switched from nose to py.test. (#204) + +* Add test matrix for all supported Python and html5lib versions. (#230) + +* Limit to html5lib ``>=0.999,!=0.9999,!=0.99999,<0.99999999`` because 0.9999 + and 0.99999 are busted. + +* Add support for ``python setup.py test``. (#97) + + +Version 1.4.3 (May 23rd, 2016) +------------------------------ + +**Security fixes** + +* None + +**Changes** + +* Limit to html5lib ``>=0.999,<0.99999999`` because of impending change to + sanitizer api. #195 + + +Version 1.4.2 (September 11, 2015) +---------------------------------- + +**Changes** + +* linkify: Fix hang in linkify with ``parse_email=True``. (#124) + +* linkify: Fix crash in linkify when removing a link that is a first-child. (#136) + +* Updated TLDs. + +* linkify: Don't remove exterior brackets when linkifying. (#146) + + +Version 1.4.1 (December 15, 2014) +--------------------------------- + +**Changes** + +* Consistent order of attributes in output. + +* Python 3.4 support. + + +Version 1.4 (January 12, 2014) +------------------------------ + +**Changes** + +* linkify: Update linkify to use etree type Treewalker instead of simpletree. + +* Updated html5lib to version ``>=0.999``. + +* Update all code to be compatible with Python 3 and 2 using six. + +* Switch to Apache License. + + +Version 1.3 +----------- + +* Used by Python 3-only fork. + + +Version 1.2.2 (May 18, 2013) +---------------------------- + +* Pin html5lib to version 0.95 for now due to major API break. + + +Version 1.2.1 (February 19, 2013) +--------------------------------- + +* ``clean()`` no longer considers ``feed:`` an acceptable protocol due to + inconsistencies in browser behavior. + + +Version 1.2 (January 28, 2013) +------------------------------ + +* ``linkify()`` has changed considerably. Many keyword arguments have been + replaced with a single callbacks list. Please see the documentation for more + information. + +* Bleach will no longer consider unacceptable protocols when linkifying. + +* ``linkify()`` now takes a tokenizer argument that allows it to skip + sanitization. + +* ``delinkify()`` is gone. + +* Removed exception handling from ``_render``. ``clean()`` and ``linkify()`` may + now throw. + +* ``linkify()`` correctly ignores case for protocols and domain names. + +* ``linkify()`` correctly handles markup within an tag. + + +Version 1.1.5 +------------- + + +Version 1.1.4 +------------- + + +Version 1.1.3 (July 10, 2012) +----------------------------- + +* Fix parsing bare URLs when parse_email=True. + + +Version 1.1.2 (June 1, 2012) +---------------------------- + +* Fix hang in style attribute sanitizer. (#61) + +* Allow ``/`` in style attribute values. + + +Version 1.1.1 (February 17, 2012) +--------------------------------- + +* Fix tokenizer for html5lib 0.9.5. + + +Version 1.1.0 (October 24, 2011) +-------------------------------- + +* ``linkify()`` now understands port numbers. (#38) + +* Documented character encoding behavior. (#41) + +* Add an optional target argument to ``linkify()``. + +* Add ``delinkify()`` method. (#45) + +* Support subdomain whitelist for ``delinkify()``. (#47, #48) + + +Version 1.0.4 (September 2, 2011) +--------------------------------- + +* Switch to SemVer git tags. + +* Make ``linkify()`` smarter about trailing punctuation. (#30) + +* Pass ``exc_info`` to logger during rendering issues. + +* Add wildcard key for attributes. (#19) + +* Make ``linkify()`` use the ``HTMLSanitizer`` tokenizer. (#36) + +* Fix URLs wrapped in parentheses. (#23) + +* Make ``linkify()`` UTF-8 safe. (#33) + + +Version 1.0.3 (June 14, 2011) +----------------------------- + +* ``linkify()`` works with 3rd level domains. (#24) + +* ``clean()`` supports vendor prefixes in style values. (#31, #32) + +* Fix ``linkify()`` email escaping. + + +Version 1.0.2 (June 6, 2011) +---------------------------- + +* ``linkify()`` supports email addresses. + +* ``clean()`` supports callables in attributes filter. + + +Version 1.0.1 (April 12, 2011) +------------------------------ + +* ``linkify()`` doesn't drop trailing slashes. (#21) +* ``linkify()`` won't linkify 'libgl.so.1'. (#22) diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD new file mode 100644 index 00000000..df992587 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/RECORD @@ -0,0 +1,103 @@ +bleach-6.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +bleach-6.1.0.dist-info/LICENSE,sha256=vsIjjBSaYyuPsmgT9oes6rq4AyfzJwdpwsFhV4g9MTA,569 +bleach-6.1.0.dist-info/METADATA,sha256=1SuJgikPmVEIDjs_NHu_oLycasw9HiTE19bLhRC8FSw,30425 +bleach-6.1.0.dist-info/RECORD,, +bleach-6.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach-6.1.0.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92 +bleach-6.1.0.dist-info/top_level.txt,sha256=dcv0wKIySB0zMjAEXLwY4V0-3IN9UZQGAT1wDmfQICY,7 +bleach/__init__.py,sha256=bCOdn7NC262aA1v98sl-lklPqeaw_5LiXqYSf-XAwUM,3649 +bleach/__pycache__/__init__.cpython-312.pyc,, +bleach/__pycache__/callbacks.cpython-312.pyc,, +bleach/__pycache__/css_sanitizer.cpython-312.pyc,, +bleach/__pycache__/html5lib_shim.cpython-312.pyc,, +bleach/__pycache__/linkifier.cpython-312.pyc,, +bleach/__pycache__/parse_shim.cpython-312.pyc,, +bleach/__pycache__/sanitizer.cpython-312.pyc,, +bleach/_vendor/README.rst,sha256=eXeKT2JdZB4WX1kuhTa8W9Jp9VXtwIKFxo5RUL5exmM,2160 +bleach/_vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/__pycache__/parse.cpython-312.pyc,, +bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst,sha256=DrNAMifoDpuQyJn-KW-H6K8Tt2a5rKnV2UF4-DRrGUI,983 +bleach/_vendor/html5lib-1.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +bleach/_vendor/html5lib-1.1.dist-info/LICENSE,sha256=FqOZkWGekvGGgJMtoqkZn999ld8-yu3FLqBiGKq6_W8,1084 +bleach/_vendor/html5lib-1.1.dist-info/METADATA,sha256=Y3w-nd_22HQnQRy3yypVsV_ke2FF94uUD4-vGpc2DnI,16076 +bleach/_vendor/html5lib-1.1.dist-info/RECORD,sha256=u-y_W5lhdsHC1OSMnA4bCi3-11IgQ_FAIW6viMu8_LA,3486 +bleach/_vendor/html5lib-1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/html5lib-1.1.dist-info/WHEEL,sha256=kGT74LWyRUZrL4VgLh6_g12IeVl_9u9ZVhadrgXZUEY,110 +bleach/_vendor/html5lib-1.1.dist-info/top_level.txt,sha256=XEX6CHpskSmvjJB4tP6m4Q5NYXhIf_0ceMc0PNbzJPQ,9 +bleach/_vendor/html5lib/__init__.py,sha256=pWnYcfZ69wNLrdQL7bpr49FUi8O8w0KhKCOHsyRgYGQ,1143 +bleach/_vendor/html5lib/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_ihatexml.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_inputstream.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_tokenizer.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/_utils.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/constants.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/html5parser.cpython-312.pyc,, +bleach/_vendor/html5lib/__pycache__/serializer.cpython-312.pyc,, +bleach/_vendor/html5lib/_ihatexml.py,sha256=ifOwF7pXqmyThIXc3boWc96s4MDezqRrRVp7FwDYUFs,16728 +bleach/_vendor/html5lib/_inputstream.py,sha256=IKuMiY8rzb7pqIGCpbvTqsxysLEpgEHWYvYEFu4LUAI,32300 +bleach/_vendor/html5lib/_tokenizer.py,sha256=WvJQa2Mli4NtTmhLXkX8Jy5FcWttqCaiDTiKyaw8D-k,77028 +bleach/_vendor/html5lib/_trie/__init__.py,sha256=nqfgO910329BEVJ5T4psVwQtjd2iJyEXQ2-X8c1YxwU,109 +bleach/_vendor/html5lib/_trie/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/__pycache__/_base.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/__pycache__/py.cpython-312.pyc,, +bleach/_vendor/html5lib/_trie/_base.py,sha256=CaybYyMro8uERQYjby2tTeSUatnWDfWroUN9N7ety5w,1013 +bleach/_vendor/html5lib/_trie/py.py,sha256=zg7RZSHxJ8mLmuI_7VEIV8AomISrgkvqCP477AgXaG0,1763 +bleach/_vendor/html5lib/_utils.py,sha256=AxAJSG15eyarCgKMnlUwzs1X6jFHXqEvhlYEOxAFmis,4919 +bleach/_vendor/html5lib/constants.py,sha256=Ll-yzLU_jcjyAI_h57zkqZ7aQWE5t5xA4y_jQgoUUhw,83464 +bleach/_vendor/html5lib/filters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +bleach/_vendor/html5lib/filters/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/alphabeticalattributes.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/inject_meta_charset.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/lint.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/optionaltags.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/sanitizer.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/__pycache__/whitespace.cpython-312.pyc,, +bleach/_vendor/html5lib/filters/alphabeticalattributes.py,sha256=lViZc2JMCclXi_5gduvmdzrRxtO5Xo9ONnbHBVCsykU,919 +bleach/_vendor/html5lib/filters/base.py,sha256=z-IU9ZAYjpsVsqmVt7kuWC63jR11hDMr6CVrvuao8W0,286 +bleach/_vendor/html5lib/filters/inject_meta_charset.py,sha256=egDXUEHXmAG9504xz0K6ALDgYkvUrC2q15YUVeNlVQg,2945 +bleach/_vendor/html5lib/filters/lint.py,sha256=upXATs6By7cot7o0bnNqR15sPq2Fn6Vnjvoy3gyO_rY,3631 +bleach/_vendor/html5lib/filters/optionaltags.py,sha256=8lWT75J0aBOHmPgfmqTHSfPpPMp01T84NKu0CRedxcE,10588 +bleach/_vendor/html5lib/filters/sanitizer.py,sha256=XGNSdzIqDTaHot1V-rRj1V_XOolApJ7n95tHP9JcgNU,26885 +bleach/_vendor/html5lib/filters/whitespace.py,sha256=8eWqZxd4UC4zlFGW6iyY6f-2uuT8pOCSALc3IZt7_t4,1214 +bleach/_vendor/html5lib/html5parser.py,sha256=w5hZJh0cvD3g4CS196DiTmuGpSKCMYe1GS46-yf_WZQ,117174 +bleach/_vendor/html5lib/serializer.py,sha256=K2kfoLyMPMFPfdusfR30SrxNkf0mJB92-P5_RntyaaI,15747 +bleach/_vendor/html5lib/treeadapters/__init__.py,sha256=18hyI-at2aBsdKzpwRwa5lGF1ipgctaTYXoU9En2ZQg,650 +bleach/_vendor/html5lib/treeadapters/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/__pycache__/genshi.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/__pycache__/sax.cpython-312.pyc,, +bleach/_vendor/html5lib/treeadapters/genshi.py,sha256=CH27pAsDKmu4ZGkAUrwty7u0KauGLCZRLPMzaO3M5vo,1715 +bleach/_vendor/html5lib/treeadapters/sax.py,sha256=BKS8woQTnKiqeffHsxChUqL4q2ZR_wb5fc9MJ3zQC8s,1776 +bleach/_vendor/html5lib/treebuilders/__init__.py,sha256=AysSJyvPfikCMMsTVvaxwkgDieELD5dfR8FJIAuq7hY,3592 +bleach/_vendor/html5lib/treebuilders/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/dom.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/etree.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/__pycache__/etree_lxml.cpython-312.pyc,, +bleach/_vendor/html5lib/treebuilders/base.py,sha256=oeZNGEB-kt90YJGVH05gb5a8E7ids2AbYwGRsVCieWk,14553 +bleach/_vendor/html5lib/treebuilders/dom.py,sha256=22whb0C71zXIsai5mamg6qzBEiigcBIvaDy4Asw3at0,8925 +bleach/_vendor/html5lib/treebuilders/etree.py,sha256=EbmHx-wQ-11MVucTPtF7Ul92-mQGN3Udu_KfDn-Ifhk,12824 +bleach/_vendor/html5lib/treebuilders/etree_lxml.py,sha256=OazDHZGO_q4FnVs4Dhs4hzzn2JwGAOs-rfV8LAlUGW4,14754 +bleach/_vendor/html5lib/treewalkers/__init__.py,sha256=OBPtc1TU5mGyy18QDMxKEyYEz0wxFUUNj5v0-XgmYhY,5719 +bleach/_vendor/html5lib/treewalkers/__pycache__/__init__.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/base.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/dom.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/etree.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/etree_lxml.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/__pycache__/genshi.cpython-312.pyc,, +bleach/_vendor/html5lib/treewalkers/base.py,sha256=ouiOsuSzvI0KgzdWP8PlxIaSNs9falhbiinAEc_UIJY,7476 +bleach/_vendor/html5lib/treewalkers/dom.py,sha256=EHyFR8D8lYNnyDU9lx_IKigVJRyecUGua0mOi7HBukc,1413 +bleach/_vendor/html5lib/treewalkers/etree.py,sha256=gkD4tfEfRWPsEGvgHHJxZmKZXUvBzVVGz3v5C_MIiOE,4539 +bleach/_vendor/html5lib/treewalkers/etree_lxml.py,sha256=eLedbn6nPjlpebibsWVijey7WEpzDwxU3ubwUoudBuA,6345 +bleach/_vendor/html5lib/treewalkers/genshi.py,sha256=4D2PECZ5n3ZN3qu3jMl9yY7B81jnQApBQSVlfaIuYbA,2309 +bleach/_vendor/parse.py,sha256=Rq-WbjO2JHrh1X2UWRFaPrRs2p-AnJ8U4FKrwv6NrLI,39023 +bleach/_vendor/parse.py.SHA256SUM,sha256=-AaiqN-9otw_X0vFjKkbKWFvkp68iLME92_wI-8-vm0,75 +bleach/_vendor/vendor.txt,sha256=6FFZyenumgWqnhLgbCa4yzL4HVNaSUDC2DHNyR5Fy6w,184 +bleach/_vendor/vendor_install.sh,sha256=x_Pn4dkfzPMJCZKwHHFxp0EAL5RsIfz-HSdTWHuI4yA,453 +bleach/callbacks.py,sha256=JNTGiM5_3bKsGltpR9ZYEz_C_b7-vfDlTTdQCirbdyc,752 +bleach/css_sanitizer.py,sha256=QFMxRKBUMSuNvYkVpB2WRBQO609eFbU-p9P_LhU6jtM,2526 +bleach/html5lib_shim.py,sha256=cWdAh70QZWz4MwtihdiA1gZJ0hTkvRjUYurE4uoCHCg,23294 +bleach/linkifier.py,sha256=vWOXKuRXirpCwejUEEyfe8EWJ7rBlieMDEerg95OhPU,22375 +bleach/parse_shim.py,sha256=VDPOdBOKbuDEceKVvfoggcr6A332bkcq4Z8jMtOJlAQ,50 +bleach/sanitizer.py,sha256=JqDuTINOybpc_eHBzG_H7cnkHdFskZGbfsaBc-hDPH8,21934 diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/REQUESTED b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/REQUESTED new file mode 100644 index 00000000..e69de29b diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL new file mode 100644 index 00000000..7e688737 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.41.2) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt new file mode 100644 index 00000000..a02d6008 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach-6.1.0.dist-info/top_level.txt @@ -0,0 +1 @@ +bleach diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py b/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py new file mode 100644 index 00000000..12e93b4d --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/__init__.py @@ -0,0 +1,125 @@ +from bleach.linkifier import ( + DEFAULT_CALLBACKS, + Linker, +) +from bleach.sanitizer import ( + ALLOWED_ATTRIBUTES, + ALLOWED_PROTOCOLS, + ALLOWED_TAGS, + Cleaner, +) + + +# yyyymmdd +__releasedate__ = "20231006" +# x.y.z or x.y.z.dev0 -- semver +__version__ = "6.1.0" + + +__all__ = ["clean", "linkify"] + + +def clean( + text, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, + strip=False, + strip_comments=True, + css_sanitizer=None, +): + """Clean an HTML fragment of malicious content and return it + + This function is a security-focused function whose sole purpose is to + remove malicious content from a string such that it can be displayed as + content in a web page. + + This function is not designed to use to transform content to be used in + non-web-page contexts. + + Example:: + + import bleach + + better_text = bleach.clean(yucky_text) + + + .. Note:: + + If you're cleaning a lot of text and passing the same argument values or + you want more configurability, consider using a + :py:class:`bleach.sanitizer.Cleaner` instance. + + :arg str text: the text to clean + + :arg set tags: set of allowed tags; defaults to + ``bleach.sanitizer.ALLOWED_TAGS`` + + :arg dict attributes: allowed attributes; can be a callable, list or dict; + defaults to ``bleach.sanitizer.ALLOWED_ATTRIBUTES`` + + :arg list protocols: allowed list of protocols for links; defaults + to ``bleach.sanitizer.ALLOWED_PROTOCOLS`` + + :arg bool strip: whether or not to strip disallowed elements + + :arg bool strip_comments: whether or not to strip HTML comments + + :arg CSSSanitizer css_sanitizer: instance with a "sanitize_css" method for + sanitizing style attribute values and style text; defaults to None + + :returns: cleaned text as unicode + + """ + cleaner = Cleaner( + tags=tags, + attributes=attributes, + protocols=protocols, + strip=strip, + strip_comments=strip_comments, + css_sanitizer=css_sanitizer, + ) + return cleaner.clean(text) + + +def linkify(text, callbacks=DEFAULT_CALLBACKS, skip_tags=None, parse_email=False): + """Convert URL-like strings in an HTML fragment to links + + This function converts strings that look like URLs, domain names and email + addresses in text that may be an HTML fragment to links, while preserving: + + 1. links already in the string + 2. urls found in attributes + 3. email addresses + + linkify does a best-effort approach and tries to recover from bad + situations due to crazy text. + + .. Note:: + + If you're linking a lot of text and passing the same argument values or + you want more configurability, consider using a + :py:class:`bleach.linkifier.Linker` instance. + + .. Note:: + + If you have text that you want to clean and then linkify, consider using + the :py:class:`bleach.linkifier.LinkifyFilter` as a filter in the clean + pass. That way you're not parsing the HTML twice. + + :arg str text: the text to linkify + + :arg list callbacks: list of callbacks to run when adjusting tag attributes; + defaults to ``bleach.linkifier.DEFAULT_CALLBACKS`` + + :arg list skip_tags: list of tags that you don't want to linkify the + contents of; for example, you could set this to ``['pre']`` to skip + linkifying contents of ``pre`` tags + + :arg bool parse_email: whether or not to linkify email addresses + + :returns: linkified text as unicode + + """ + linker = Linker(callbacks=callbacks, skip_tags=skip_tags, parse_email=parse_email) + return linker.linkify(text) diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..25b2c08e Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc new file mode 100644 index 00000000..f05236ac Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/callbacks.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/css_sanitizer.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/css_sanitizer.cpython-312.pyc new file mode 100644 index 00000000..0e57c517 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/css_sanitizer.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc new file mode 100644 index 00000000..7c9264f3 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/html5lib_shim.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc new file mode 100644 index 00000000..3ab87ba6 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/linkifier.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/parse_shim.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/parse_shim.cpython-312.pyc new file mode 100644 index 00000000..2faa0926 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/parse_shim.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/sanitizer.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/sanitizer.cpython-312.pyc new file mode 100644 index 00000000..6f695eab Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/__pycache__/sanitizer.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/README.rst b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/README.rst new file mode 100644 index 00000000..e53aede0 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/README.rst @@ -0,0 +1,61 @@ +======================= +Vendored library policy +======================= + +To simplify Bleach development, we're now vendoring certain libraries that +we use. + +Vendored libraries must follow these rules: + +1. Vendored libraries must be pure Python--no compiling. +2. Source code for the libary is included in this directory. +3. License must be included in this repo and in the Bleach distribution. +4. Requirements of the library become requirements of Bleach. +5. No modifications to the library may be made. + + +Adding/Updating a vendored library +================================== + +Way to vendor a library or update a version: + +1. Update ``vendor.txt`` with the library, version, and hash. You can use + `hashin `_. +2. Remove all old files and directories of the old version. +3. Run ``pip_install_vendor.sh`` and check everything it produced in including + the ``.dist-info`` directory and contents. +4. Update the bleach minor version in the next release. + + +Reviewing a change involving a vendored library +=============================================== + +Way to verify a vendored library addition/update: + +1. Pull down the branch. +2. Delete all the old files and directories of the old version. +3. Run ``pip_install_vendor.sh``. +4. Run ``git diff`` and verify there are no changes. + + +NB: the current ``vendor.txt`` was generated with pip 20.2.3, which might be necessary to reproduce the dist-info + + +Removing/Unvendoring a vendored library +======================================= + +A vendored library might be removed for any of the following reasons: + +* it violates the vendoring policy (e.g. an incompatible license + change) +* a suitable replacement is found +* bleach has the resources to test and QA new bleach releases against + multiple versions of the previously vendored library + +To unvendor a library: + +1. Remove the library and its hashes from ``vendor.txt``. +2. Remove library files and directories from this directory. +3. Run ``install_vendor.sh`` and check the previously vendored library including + the ``.dist-info`` directory and contents is not installed. +4. Update the bleach minor version in the next release. diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__init__.py b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..2a651707 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc new file mode 100644 index 00000000..f217c156 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/__pycache__/parse.cpython-312.pyc differ diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst new file mode 100644 index 00000000..90401390 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst @@ -0,0 +1,66 @@ +Credits +======= + +``html5lib`` is written and maintained by: + +- James Graham +- Sam Sneddon +- Łukasz Langa +- Will Kahn-Greene + + +Patches and suggestions +----------------------- +(In chronological order, by first commit:) + +- Anne van Kesteren +- Lachlan Hunt +- lantis63 +- Sam Ruby +- Thomas Broyer +- Tim Fletcher +- Mark Pilgrim +- Ryan King +- Philip Taylor +- Edward Z. Yang +- fantasai +- Philip Jägenstedt +- Ms2ger +- Mohammad Taha Jahangir +- Andy Wingo +- Andreas Madsack +- Karim Valiev +- Juan Carlos Garcia Segovia +- Mike West +- Marc DM +- Simon Sapin +- Michael[tm] Smith +- Ritwik Gupta +- Marc Abramowitz +- Tony Lopes +- lilbludevil +- Kevin +- Drew Hubl +- Austin Kumbera +- Jim Baker +- Jon Dufresne +- Donald Stufft +- Alex Gaynor +- Nik Nyby +- Jakub Wilk +- Sigmund Cherem +- Gabi Davar +- Florian Mounier +- neumond +- Vitalik Verhovodov +- Kovid Goyal +- Adam Chainz +- John Vandenberg +- Eric Amorde +- Benedikt Morbach +- Jonathan Vanasco +- Tom Most +- Ville Skyttä +- Hugo van Kemenade +- Mark Vasilkov + diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/INSTALLER b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/LICENSE b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/LICENSE new file mode 100644 index 00000000..c87fa7a0 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2006-2013 James Graham and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/METADATA b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/METADATA new file mode 100644 index 00000000..ee83c1f8 --- /dev/null +++ b/Backend/venv/lib/python3.12/site-packages/bleach/_vendor/html5lib-1.1.dist-info/METADATA @@ -0,0 +1,552 @@ +Metadata-Version: 2.1 +Name: html5lib +Version: 1.1 +Summary: HTML parser based on the WHATWG HTML specification +Home-page: https://github.com/html5lib/html5lib-python +Maintainer: James Graham +Maintainer-email: james@hoppipolla.co.uk +License: MIT License +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Text Processing :: Markup :: HTML +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* +Requires-Dist: six (>=1.9) +Requires-Dist: webencodings +Provides-Extra: all +Requires-Dist: genshi ; extra == 'all' +Requires-Dist: chardet (>=2.2) ; extra == 'all' +Requires-Dist: lxml ; (platform_python_implementation == 'CPython') and extra == 'all' +Provides-Extra: chardet +Requires-Dist: chardet (>=2.2) ; extra == 'chardet' +Provides-Extra: genshi +Requires-Dist: genshi ; extra == 'genshi' +Provides-Extra: lxml +Requires-Dist: lxml ; (platform_python_implementation == 'CPython') and extra == 'lxml' + +html5lib +======== + +.. image:: https://travis-ci.org/html5lib/html5lib-python.svg?branch=master + :target: https://travis-ci.org/html5lib/html5lib-python + + +html5lib is a pure-python library for parsing HTML. It is designed to +conform to the WHATWG HTML specification, as is implemented by all major +web browsers. + + +Usage +----- + +Simple usage follows this pattern: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + document = html5lib.parse(f) + +or: + +.. code-block:: python + + import html5lib + document = html5lib.parse("

Hello World!") + +By default, the ``document`` will be an ``xml.etree`` element instance. +Whenever possible, html5lib chooses the accelerated ``ElementTree`` +implementation (i.e. ``xml.etree.cElementTree`` on Python 2.x). + +Two other tree types are supported: ``xml.dom.minidom`` and +``lxml.etree``. To use an alternative format, specify the name of +a treebuilder: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + lxml_etree_document = html5lib.parse(f, treebuilder="lxml") + +When using with ``urllib2`` (Python 2), the charset from HTTP should be +pass into html5lib as follows: + +.. code-block:: python + + from contextlib import closing + from urllib2 import urlopen + import html5lib + + with closing(urlopen("http://example.com/")) as f: + document = html5lib.parse(f, transport_encoding=f.info().getparam("charset")) + +When using with ``urllib.request`` (Python 3), the charset from HTTP +should be pass into html5lib as follows: + +.. code-block:: python + + from urllib.request import urlopen + import html5lib + + with urlopen("http://example.com/") as f: + document = html5lib.parse(f, transport_encoding=f.info().get_content_charset()) + +To have more control over the parser, create a parser object explicitly. +For instance, to make the parser raise exceptions on parse errors, use: + +.. code-block:: python + + import html5lib + with open("mydocument.html", "rb") as f: + parser = html5lib.HTMLParser(strict=True) + document = parser.parse(f) + +When you're instantiating parser objects explicitly, pass a treebuilder +class as the ``tree`` keyword argument to use an alternative document +format: + +.. code-block:: python + + import html5lib + parser = html5lib.HTMLParser(tree=html5lib.getTreeBuilder("dom")) + minidom_document = parser.parse("

Hello World!") + +More documentation is available at https://html5lib.readthedocs.io/. + + +Installation +------------ + +html5lib works on CPython 2.7+, CPython 3.5+ and PyPy. To install: + +.. code-block:: bash + + $ pip install html5lib + +The goal is to support a (non-strict) superset of the versions that `pip +supports +`_. + +Optional Dependencies +--------------------- + +The following third-party libraries may be used for additional +functionality: + +- ``lxml`` is supported as a tree format (for both building and + walking) under CPython (but *not* PyPy where it is known to cause + segfaults); + +- ``genshi`` has a treewalker (but not builder); and + +- ``chardet`` can be used as a fallback when character encoding cannot + be determined. + + +Bugs +---- + +Please report any bugs on the `issue tracker +`_. + + +Tests +----- + +Unit tests require the ``pytest`` and ``mock`` libraries and can be +run using the ``py.test`` command in the root directory. + +Test data are contained in a separate `html5lib-tests +`_ repository and included +as a submodule, thus for git checkouts they must be initialized:: + + $ git submodule init + $ git submodule update + +If you have all compatible Python implementations available on your +system, you can run tests on all of them using the ``tox`` utility, +which can be found on PyPI. + + +Questions? +---------- + +There's a mailing list available for support on Google Groups, +`html5lib-discuss `_, +though you may get a quicker response asking on IRC in `#whatwg on +irc.freenode.net `_. + +Change Log +---------- + +1.1 +~~~ + +UNRELEASED + +Breaking changes: + +* Drop support for Python 3.3. (#358) +* Drop support for Python 3.4. (#421) + +Deprecations: + +* Deprecate the ``html5lib`` sanitizer (``html5lib.serialize(sanitize=True)`` and + ``html5lib.filters.sanitizer``). We recommend users migrate to `Bleach + `. Please let us know if Bleach doesn't suffice for your + use. (#443) + +Other changes: + +* Try to import from ``collections.abc`` to remove DeprecationWarning and ensure + ``html5lib`` keeps working in future Python versions. (#403) +* Drop optional ``datrie`` dependency. (#442) + + +1.0.1 +~~~~~ + +Released on December 7, 2017 + +Breaking changes: + +* Drop support for Python 2.6. (#330) (Thank you, Hugo, Will Kahn-Greene!) +* Remove ``utils/spider.py`` (#353) (Thank you, Jon Dufresne!) + +Features: + +* Improve documentation. (#300, #307) (Thank you, Jon Dufresne, Tom Most, + Will Kahn-Greene!) +* Add iframe seamless boolean attribute. (Thank you, Ritwik Gupta!) +* Add itemscope as a boolean attribute. (#194) (Thank you, Jonathan Vanasco!) +* Support Python 3.6. (#333) (Thank you, Jon Dufresne!) +* Add CI support for Windows using AppVeyor. (Thank you, John Vandenberg!) +* Improve testing and CI and add code coverage (#323, #334), (Thank you, Jon + Dufresne, John Vandenberg, Sam Sneddon, Will Kahn-Greene!) +* Semver-compliant version number. + +Bug fixes: + +* Add support for setuptools < 18.5 to support environment markers. (Thank you, + John Vandenberg!) +* Add explicit dependency for six >= 1.9. (Thank you, Eric Amorde!) +* Fix regexes to work with Python 3.7 regex adjustments. (#318, #379) (Thank + you, Benedikt Morbach, Ville Skyttä, Mark Vasilkov!) +* Fix alphabeticalattributes filter namespace bug. (#324) (Thank you, Will + Kahn-Greene!) +* Include license file in generated wheel package. (#350) (Thank you, Jon + Dufresne!) +* Fix annotation-xml typo. (#339) (Thank you, Will Kahn-Greene!) +* Allow uppercase hex chararcters in CSS colour check. (#377) (Thank you, + Komal Dembla, Hugo!) + + +1.0 +~~~ + +Released and unreleased on December 7, 2017. Badly packaged release. + + +0.999999999/1.0b10 +~~~~~~~~~~~~~~~~~~ + +Released on July 15, 2016 + +* Fix attribute order going to the tree builder to be document order + instead of reverse document order(!). + + +0.99999999/1.0b9 +~~~~~~~~~~~~~~~~ + +Released on July 14, 2016 + +* **Added ordereddict as a mandatory dependency on Python 2.6.** + +* Added ``lxml``, ``genshi``, ``datrie``, ``charade``, and ``all`` + extras that will do the right thing based on the specific + interpreter implementation. + +* Now requires the ``mock`` package for the testsuite. + +* Cease supporting DATrie under PyPy. + +* **Remove PullDOM support, as this hasn't ever been properly + tested, doesn't entirely work, and as far as I can tell is + completely unused by anyone.** + +* Move testsuite to ``py.test``. + +* **Fix #124: move to webencodings for decoding the input byte stream; + this makes html5lib compliant with the Encoding Standard, and + introduces a required dependency on webencodings.** + +* **Cease supporting Python 3.2 (in both CPython and PyPy forms).** + +* **Fix comments containing double-dash with lxml 3.5 and above.** + +* **Use scripting disabled by default (as we don't implement + scripting).** + +* **Fix #11, avoiding the XSS bug potentially caused by serializer + allowing attribute values to be escaped out of in old browser versions, + changing the quote_attr_values option on serializer to take one of + three values, "always" (the old True value), "legacy" (the new option, + and the new default), and "spec" (the old False value, and the old + default).** + +* **Fix #72 by rewriting the sanitizer to apply only to treewalkers + (instead of the tokenizer); as such, this will require amending all + callers of it to use it via the treewalker API.** + +* **Drop support of charade, now that chardet is supported once more.** + +* **Replace the charset keyword argument on parse and related methods + with a set of keyword arguments: override_encoding, transport_encoding, + same_origin_parent_encoding, likely_encoding, and default_encoding.** + +* **Move filters._base, treebuilder._base, and treewalkers._base to .base + to clarify their status as public.** + +* **Get rid of the sanitizer package. Merge sanitizer.sanitize into the + sanitizer.htmlsanitizer module and move that to sanitizer. This means + anyone who used sanitizer.sanitize or sanitizer.HTMLSanitizer needs no + code changes.** + +* **Rename treewalkers.lxmletree to .etree_lxml and + treewalkers.genshistream to .genshi to have a consistent API.** + +* Move a whole load of stuff (inputstream, ihatexml, trie, tokenizer, + utils) to be underscore prefixed to clarify their status as private. + + +0.9999999/1.0b8 +~~~~~~~~~~~~~~~ + +Released on September 10, 2015 + +* Fix #195: fix the sanitizer to drop broken URLs (it threw an + exception between 0.9999 and 0.999999). + + +0.999999/1.0b7 +~~~~~~~~~~~~~~ + +Released on July 7, 2015 + +* Fix #189: fix the sanitizer to allow relative URLs again (as it did + prior to 0.9999/1.0b5). + + +0.99999/1.0b6 +~~~~~~~~~~~~~ + +Released on April 30, 2015 + +* Fix #188: fix the sanitizer to not throw an exception when sanitizing + bogus data URLs. + + +0.9999/1.0b5 +~~~~~~~~~~~~ + +Released on April 29, 2015 + +* Fix #153: Sanitizer fails to treat some attributes as URLs. Despite how + this sounds, this has no known security implications. No known version + of IE (5.5 to current), Firefox (3 to current), Safari (6 to current), + Chrome (1 to current), or Opera (12 to current) will run any script + provided in these attributes. + +* Pass error message to the ParseError exception in strict parsing mode. + +* Allow data URIs in the sanitizer, with a whitelist of content-types. + +* Add support for Python implementations that don't support lone + surrogates (read: Jython). Fixes #2. + +* Remove localization of error messages. This functionality was totally + unused (and untested that everything was localizable), so we may as + well follow numerous browsers in not supporting translating technical + strings. + +* Expose treewalkers.pprint as a public API. + +* Add a documentEncoding property to HTML5Parser, fix #121. + + +0.999 +~~~~~ + +Released on December 23, 2013 + +* Fix #127: add work-around for CPython issue #20007: .read(0) on + http.client.HTTPResponse drops the rest of the content. + +* Fix #115: lxml treewalker can now deal with fragments containing, at + their root level, text nodes with non-ASCII characters on Python 2. + + +0.99 +~~~~ + +Released on September 10, 2013 + +* No library changes from 1.0b3; released as 0.99 as pip has changed + behaviour from 1.4 to avoid installing pre-release versions per + PEP 440. + + +1.0b3 +~~~~~ + +Released on July 24, 2013 + +* Removed ``RecursiveTreeWalker`` from ``treewalkers._base``. Any + implementation using it should be moved to + ``NonRecursiveTreeWalker``, as everything bundled with html5lib has + for years. + +* Fix #67 so that ``BufferedStream`` to correctly returns a bytes + object, thereby fixing any case where html5lib is passed a + non-seekable RawIOBase-like object. + + +1.0b2 +~~~~~ + +Released on June 27, 2013 + +* Removed reordering of attributes within the serializer. There is now + an ``alphabetical_attributes`` option which preserves the previous + behaviour through a new filter. This allows attribute order to be + preserved through html5lib if the tree builder preserves order. + +* Removed ``dom2sax`` from DOM treebuilders. It has been replaced by + ``treeadapters.sax.to_sax`` which is generic and supports any + treewalker; it also resolves all known bugs with ``dom2sax``. + +* Fix treewalker assertions on hitting bytes strings on + Python 2. Previous to 1.0b1, treewalkers coped with mixed + bytes/unicode data on Python 2; this reintroduces this prior + behaviour on Python 2. Behaviour is unchanged on Python 3. + + +1.0b1 +~~~~~ + +Released on May 17, 2013 + +* Implementation updated to implement the `HTML specification + `_ as of 5th May + 2013 (`SVN `_ revision r7867). + +* Python 3.2+ supported in a single codebase using the ``six`` library. + +* Removed support for Python 2.5 and older. + +* Removed the deprecated Beautiful Soup 3 treebuilder. + ``beautifulsoup4`` can use ``html5lib`` as a parser instead. Note that + since it doesn't support namespaces, foreign content like SVG and + MathML is parsed incorrectly. + +* Removed ``simpletree`` from the package. The default tree builder is + now ``etree`` (using the ``xml.etree.cElementTree`` implementation if + available, and ``xml.etree.ElementTree`` otherwise). + +* Removed the ``XHTMLSerializer`` as it never actually guaranteed its + output was well-formed XML, and hence provided little of use. + +* Removed default DOM treebuilder, so ``html5lib.treebuilders.dom`` is no + longer supported. ``html5lib.treebuilders.getTreeBuilder("dom")`` will + return the default DOM treebuilder, which uses ``xml.dom.minidom``. + +* Optional heuristic character encoding detection now based on + ``charade`` for Python 2.6 - 3.3 compatibility. + +* Optional ``Genshi`` treewalker support fixed. + +* Many bugfixes, including: + + * #33: null in attribute value breaks XML AttValue; + + * #4: nested, indirect descendant,