""" app.py ====== Application entry point and factory. This file is intentionally lean (~130 lines). All route logic lives in the Blueprint modules under routes/. All shared utilities live in utils/. The db and logger_handler singletons live in extensions.py. Blueprint registration order matches the original route-definition order so that url_for() resolution is identical to the original monolithic app.py. """ from flask import Flask, render_template, request, redirect, url_for, flash, session, g from datetime import datetime from dotenv import load_dotenv import os import time as _time # Load .env BEFORE importing anything that reads env vars load_dotenv() from extensions import db, init_logger from config import get_config from utils.template_helpers import register_template_helpers from logger_handler import log_database_operations from models import set_db from turnstile_utils import turnstile_utils from db_performance_optimization import initialize_performance_optimizations from utils.helpers import has_admin_privileges # --------------------------------------------------------------------------- # Application factory # --------------------------------------------------------------------------- from location_logging import * # noqa: F401,F403 — registers location hooks at module level def create_app() -> Flask: app = Flask(__name__) # ------------------------------------------------------------------ # Configuration # ------------------------------------------------------------------ # Load configuration from config.py (single source of truth for env vars) cfg = get_config() app.config.from_object(cfg) # Real client IP behind Nginx: ProxyFix takes it from the X-Forwarded-For # entry appended by our own proxy (TRUSTED_PROXY_COUNT hops), never from the # first, client-supplied entry. The login rate limiter depends on it. _trusted_proxies = app.config.get('TRUSTED_PROXY_COUNT', 1) if _trusted_proxies > 0: from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=_trusted_proxies) # Guard against deployment with the insecure default SECRET_KEY import sys if not app.debug and app.config.get('SECRET_KEY') == 'change-me-in-production': print("FATAL: SECRET_KEY is set to the insecure default value. " "Set SECRET_KEY in your .env file before deploying to production.") sys.exit(1) # ------------------------------------------------------------------ # Database initialization # ------------------------------------------------------------------ db.init_app(app) with app.app_context(): # Unpack model classes and store on app for shared access (User, QRCode, QRCodeStyle, QRCodeLocation, Project, AttendanceData, Employee, TimeAttendance, UserProjectPermission, UserLocationPermission) = set_db(db) # ADDED: QRCodeLocation # ------------------------------------------------------------------ # Logger initialization # ------------------------------------------------------------------ init_logger(app, db) # ------------------------------------------------------------------ # Blueprint registration (url_prefix='' preserves all original URLs) # ------------------------------------------------------------------ from routes.auth import bp as auth_bp from routes.dashboard import bp as dashboard_bp from routes.users import bp as users_bp from routes.admin import bp as admin_bp from routes.projects import bp as projects_bp from routes.qr_codes import bp as qr_codes_bp from routes.attendance import bp as attendance_bp # Import sub-modules to register their routes on the shared attendance blueprint. # These are side-effect imports — do not register their bp separately. import routes.attendance_edit # noqa: F401 import routes.verification # noqa: F401 import routes.attendance_export # noqa: F401 from routes.statistics import bp as statistics_bp from routes.employees import bp as employees_bp from routes.time_attendance import bp as time_attendance_bp from routes.legacy_attendance import bp as legacy_attendance_bp for bp in (auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp, qr_codes_bp, attendance_bp, statistics_bp, employees_bp, time_attendance_bp, legacy_attendance_bp): app.register_blueprint(bp) # Register location-logging routes (from location_logging.py) # Must be called after app is created; uses app, db, logger_handler directly. from extensions import logger_handler as _lh create_location_logging_routes(app, db, _lh) # ------------------------------------------------------------------ # Security: CSRF protection + rate-limiting via SecurityManager # ------------------------------------------------------------------ from advanced_security_middleware import SecurityManager, generate_csrf_token from extensions import logger_handler as _lh2 security_manager = SecurityManager() security_manager.init_app(app, db, _lh2) # Endpoints exempt from CSRF validation: # - login / register (no session token exists yet) # - qr_checkin (public, unauthenticated QR scan endpoint) _CSRF_EXEMPT = {'auth.login', 'auth.register', 'qr_codes.qr_checkin', 'static'} @app.before_request def csrf_protect(): """Validate CSRF token on every state-mutating request.""" if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'): return if request.endpoint in _CSRF_EXEMPT: return token = (request.form.get('csrf_token') or request.headers.get('X-CSRF-Token')) expected = session.get('csrf_token') import hmac as _hmac if not token or not expected or not _hmac.compare_digest(token, expected): _lh2.logger.warning( f"CSRF validation failed | endpoint={request.endpoint} " f"| ip={request.remote_addr} | user={session.get('username','anon')}" ) from flask import abort abort(403) # Make generate_csrf_token() available in every template as csrf_token() @app.context_processor def inject_csrf_token(): return {'csrf_token': generate_csrf_token} # Expose security_manager to routes that need it (login rate-limiting) app.security_manager = security_manager # ------------------------------------------------------------------ # Template filters (global — must be on app, not blueprints) # ------------------------------------------------------------------ @app.context_processor def inject_company_name(): """Make COMPANY_NAME and THEME_NAME available to all templates""" return { 'COMPANY_NAME': os.environ.get('COMPANY_NAME', 'QR Code Management System'), 'THEME_NAME': os.environ.get('THEME_NAME', ''), 'CURRENT_YEAR': datetime.now().year, # local time (§18) } @app.context_processor def inject_logging_status(): """Inject logging status into all templates""" return { 'logging_enabled': True, 'is_admin': has_admin_privileges(session.get('role', '')) } @app.context_processor def inject_turnstile(): """Inject Turnstile settings into all templates""" return { 'turnstile_enabled': turnstile_utils.is_enabled(), 'turnstile_site_key': turnstile_utils.get_site_key() } # Register template helper context processors (from utils/template_helpers.py) register_template_helpers(app) @app.template_filter('strftime') def strftime_filter(value, format='%m/%d/%Y'): """Format datetime/date/string as strftime""" if isinstance(value, str): if value.lower() == 'now': return datetime.now().strftime(format) try: dt = datetime.fromisoformat(value) return dt.strftime(format) except (ValueError, TypeError): return value if hasattr(value, 'strftime'): return value.strftime(format) return str(value) @app.template_filter('days_since') def days_since_filter(value): """Calculate days since a given date""" if not value: return 0 now = datetime.utcnow() return (now - value).days @app.template_filter('time_ago') def time_ago_filter(value): """Human readable time ago""" if not value: return 'Never' now = datetime.utcnow() diff = now - value if diff.days > 365: years = diff.days // 365 return f"{years} year{'s' if years != 1 else ''} ago" elif diff.days > 30: months = diff.days // 30 return f"{months} month{'s' if months != 1 else ''} ago" elif diff.days > 0: return f"{diff.days} day{'s' if diff.days != 1 else ''} ago" elif diff.seconds > 3600: hours = diff.seconds // 3600 return f"{hours} hour{'s' if hours != 1 else ''} ago" elif diff.seconds > 60: minutes = diff.seconds // 60 return f"{minutes} minute{'s' if minutes != 1 else ''} ago" else: return "Just now" # ------------------------------------------------------------------ # Request / response hooks # ------------------------------------------------------------------ @app.before_request def adjust_session_lifetime(): """ Enforce the session lifetime chosen at login. app.permanent_session_lifetime is deliberately NOT changed here. It is shared by every request in the worker, and Flask checks the session cookie's age against it in open_session() BEFORE this hook runs. Setting it to 10 hours on requests without Remember Me (QR check-ins, the login page) made any Remember Me cookie unused for 10 hours fail to load, and under gevent a concurrent request could also stamp a 10-hour expiry on a Remember Me cookie. It stays at Config.PERMANENT_SESSION_LIFETIME. - Remember Me: permanent cookie, 30 days, renewed on every request. - Otherwise: browser-session cookie, and the login ends 10 hours after sign-in (session['login_epoch'], set in auth.login). """ if 'user_id' not in session or session.get('remember_me'): return login_epoch = session.get('login_epoch') if login_epoch is None: # Logged in before login_epoch existed: start the 10 hours now session['login_epoch'] = _time.time() elif _time.time() - login_epoch > 10 * 3600: session.clear() @app.before_request def log_request_info(): """Record request start time and scan for suspicious user agents""" # Always record start time for slow-query detection in after_request g.start_time = _time.time() if (request.endpoint and (request.endpoint.startswith('static') or request.path.startswith('/api/logs'))): return from extensions import logger_handler as lh user_agent = request.headers.get('User-Agent', '') ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) suspicious_patterns = [ 'sqlmap', 'nikto', 'nmap', 'dirb', 'dirbuster', 'wget', 'curl.*bot', 'scanner', 'exploit' ] if any(pattern in user_agent.lower() for pattern in suspicious_patterns): lh.log_security_event( event_type="suspicious_user_agent", description=f"Suspicious user agent detected: {user_agent[:200]}", severity="HIGH", additional_data={'user_agent': user_agent, 'ip_address': ip_address} ) @app.after_request def log_response_info(response): """Log slow requests and error responses for performance and health monitoring""" from extensions import logger_handler as lh if request.endpoint and request.endpoint.startswith('static'): return response if hasattr(g, 'start_time'): duration = _time.time() - g.start_time if duration > 2.0: lh.log_system_event( event_type="slow_query_detected", description=f"Slow request: {request.endpoint} took {duration:.2f}s", severity="WARNING", additional_data={ 'duration': duration, 'endpoint': request.endpoint, 'method': request.method, 'user': session.get('username', 'anonymous') } ) if response.status_code >= 400: lh.logger.warning( f"Error response: {response.status_code} for {request.path} " f"by user {session.get('username', 'anonymous')}" ) return response # ------------------------------------------------------------------ # Error handlers # ------------------------------------------------------------------ @app.errorhandler(403) def forbidden(error): """Handle forbidden access errors""" return render_template('errors/403.html'), 403 @app.errorhandler(404) def not_found(error): """Handle page not found errors""" return render_template('errors/404.html'), 404 @app.errorhandler(500) def internal_error(error): """Handle internal server errors with user-friendly page""" return render_template('errors/500.html'), 500 @app.errorhandler(413) def request_too_large(error): """Request over MAX_CONTENT_LENGTH, or a form field over MAX_FORM_MEMORY_SIZE""" from flask import jsonify message = 'The upload is too large. / El archivo es demasiado grande.' if (request.path.startswith(('/qr/', '/api/')) or request.headers.get('X-Requested-With') == 'XMLHttpRequest'): return jsonify({'success': False, 'message': message}), 413 flash(message, 'error') referrer = request.referrer or '' if not referrer.startswith(request.host_url): referrer = url_for('dashboard.dashboard') return redirect(referrer) # ------------------------------------------------------------------ # Startup initialization (runs under gunicorn and flask run alike) # ------------------------------------------------------------------ with app.app_context(): try: create_tables() update_existing_qr_codes() except Exception as e: from extensions import logger_handler as _startup_lh _startup_lh.logger.error(f"Startup initialization failed: {e}", exc_info=True) raise return app # --------------------------------------------------------------------------- # Database initialization helpers (called at startup) # --------------------------------------------------------------------------- @log_database_operations('database_initialization') def create_tables(): """Create database tables and default admin user with logging""" from extensions import db as _db, logger_handler as lh try: _db.create_all() from flask import current_app from models.user import User admin = User.query.filter_by(username='admin').first() if not admin: from config import Config as _Cfg default_password = _Cfg.DEFAULT_ADMIN_PASSWORD admin = User( full_name='System Administrator', email='admin@example.com', username='admin', role='admin' ) admin.set_password(default_password) _db.session.add(admin) _db.session.commit() if default_password == 'admin123': print("⚠️ WARNING: Default admin password 'admin123' is in use. " "Set DEFAULT_ADMIN_PASSWORD in your .env file before going to production.") lh.logger.warning( "Default admin user created with insecure default password. " "Set DEFAULT_ADMIN_PASSWORD environment variable." ) else: lh.logger.info("Default admin user created during initialization") lh._create_log_table() except Exception as e: lh.log_database_error('database_initialization', e) raise def update_existing_qr_codes(): """Update existing QR codes with missing URLs or images at startup. Regenerates qr_url slugs without needing a request context. For qr_code_image, constructs the base URL from FLASK_HOST/FLASK_PORT config so this can run safely outside any HTTP request. """ from extensions import db as _db, logger_handler as lh from utils.helpers import generate_qr_code, get_qr_styling, generate_qr_url from config import Config as _Cfg try: from models.qrcode import QRCode qr_codes = QRCode.query.filter_by(active_status=True).all() if not qr_codes: return # Public base URL for QR images generated here. There is no request at # startup, so it can only come from QR_BASE_URL (e.g. # https://qr.govservicesinc.com). Without it no image is generated: the # old FLASK_HOST/FLASK_PORT fallback printed http://localhost:5000/... # into QR codes that no phone can open. Routes still build images from # the request when a QR code is created or its styling is edited. base_url = (_Cfg.QR_BASE_URL or '').rstrip('/') base_url = f"{base_url}/" if base_url else '' if not base_url: lh.logger.info("Startup: QR_BASE_URL not set — missing QR images are not generated at startup") updated_count = 0 for qr_code in qr_codes: if not qr_code.qr_url or (not qr_code.qr_code_image and base_url): try: if not qr_code.qr_url: qr_code.qr_url = generate_qr_url(qr_code.name, qr_code.id) if not qr_code.qr_code_image and base_url: qr_data = f"{base_url}qr/{qr_code.qr_url}" styling = get_qr_styling(qr_code) qr_code.qr_code_image = generate_qr_code( data=qr_data, fill_color=styling['fill_color'], back_color=styling['back_color'], box_size=styling['box_size'], border=styling['border'], error_correction=styling['error_correction'] ) updated_count += 1 except Exception as e: lh.log_flask_error('qr_code_update_error', f"Failed to update QR code {qr_code.id}: {str(e)}") continue if updated_count > 0: _db.session.commit() lh.logger.info(f"Startup: updated {updated_count} QR codes with missing URLs/images") except Exception as e: lh.log_database_error('update_existing_qr_codes', e) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- app = create_app() if __name__ == '__main__': with app.app_context(): try: from extensions import logger_handler logger_handler.logger.info("Initializing performance optimizations") from app_performance_middleware import PerformanceMonitor # dev-mode only cached_query = initialize_performance_optimizations(app, db, logger_handler) performance_monitor = PerformanceMonitor(app, db, logger_handler) if cached_query: logger_handler.logger.info("Performance optimizations completed successfully") else: logger_handler.logger.warning("Performance optimizations completed with warnings") logger_handler.logger.info("QR Attendance Management System started successfully") except Exception as e: print(f"❌ Application startup failed: {e}") raise from config import Config as _Cfg app.run( debug=_Cfg.DEBUG, host=_Cfg.FLASK_HOST, port=_Cfg.FLASK_PORT, threaded=_Cfg.THREADED )