diff --git a/application/single_app/admin_settings_int_utils.py b/application/single_app/admin_settings_int_utils.py new file mode 100644 index 00000000..70e41b5e --- /dev/null +++ b/application/single_app/admin_settings_int_utils.py @@ -0,0 +1,38 @@ +# admin_settings_int_utils.py + + +def safe_int_with_source(raw_value, fallback_value, hard_default=0): + """ + Safely parse an integer value using raw input, fallback, then hard default. + + Args: + raw_value (object): Primary value to parse. + fallback_value (object): Secondary value to parse when raw parsing fails. + hard_default (int): Final integer default when both values are invalid. + + Returns: + tuple[int, str]: Parsed integer and parse source (`raw`, `fallback`, `hard_default`). + """ + try: + return int(raw_value), "raw" + except (TypeError, ValueError): + try: + return int(fallback_value), "fallback" + except (TypeError, ValueError): + return int(hard_default), "hard_default" + + +def safe_int(raw_value, fallback_value, hard_default=0): + """ + Safely parse an integer using raw input, fallback, then hard default. + + Args: + raw_value (object): Primary value to parse. + fallback_value (object): Secondary value to parse when raw parsing fails. + hard_default (int): Final integer default when both values are invalid. + + Returns: + int: Parsed integer value. + """ + parsed_value, _ = safe_int_with_source(raw_value, fallback_value, hard_default) + return parsed_value diff --git a/application/single_app/app.py b/application/single_app/app.py index ca74071a..1b1adc34 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -311,10 +311,238 @@ def initialize_application(force=False): def ensure_application_initialized(): initialize_application() + +def get_idle_timeout_settings(settings=None): + """ + Resolve and normalize idle timeout settings used for warning and logout enforcement. + + Args: + settings (dict, optional): Settings dictionary to use. If None, uses request-scoped settings. + + Returns: + tuple[int, int]: A tuple of (idle_timeout_minutes, idle_warning_minutes) + after parsing, fallback handling, and boundary normalization. + + Raises: + None: Invalid values are handled via fallback defaults and warning logs. + """ + if settings is None: + settings = get_request_settings() + + timeout_raw = settings.get('idle_timeout_minutes', 30) + warning_raw = settings.get('idle_warning_minutes', 28) + + try: + timeout_minutes = int(timeout_raw) + except (TypeError, ValueError): + timeout_minutes = 30 + log_event( + "Invalid idle timeout value detected; using default.", + extra={ + "setting": "idle_timeout_minutes", + "raw_value": str(timeout_raw), + "fallback_value": 30 + }, + level=logging.WARNING + ) + + try: + warning_minutes = int(warning_raw) + except (TypeError, ValueError): + warning_minutes = 28 + log_event( + "Invalid idle warning value detected; using default.", + extra={ + "setting": "idle_warning_minutes", + "raw_value": str(warning_raw), + "fallback_value": 28 + }, + level=logging.WARNING + ) + + normalized_timeout = max(10, timeout_minutes) + if normalized_timeout != timeout_minutes: + log_event( + "Idle timeout value normalized to minimum allowed value.", + extra={ + "setting": "idle_timeout_minutes", + "original_value": timeout_minutes, + "normalized_value": normalized_timeout + }, + level=logging.WARNING + ) + timeout_minutes = normalized_timeout + + normalized_warning = max(0, warning_minutes) + if normalized_warning != warning_minutes: + log_event( + "Idle warning value normalized to minimum allowed value.", + extra={ + "setting": "idle_warning_minutes", + "original_value": warning_minutes, + "normalized_value": normalized_warning + }, + level=logging.WARNING + ) + warning_minutes = normalized_warning + + if warning_minutes > timeout_minutes: + previous_warning_minutes = warning_minutes + warning_minutes = timeout_minutes + log_event( + "Idle warning value adjusted to not exceed idle timeout.", + extra={ + "idle_timeout_minutes": timeout_minutes, + "original_idle_warning_minutes": previous_warning_minutes, + "adjusted_idle_warning_minutes": warning_minutes + }, + level=logging.WARNING + ) + + return timeout_minutes, warning_minutes + + +def is_idle_timeout_enabled(settings=None): + """ + Determine whether idle-timeout enforcement is enabled. + + Args: + settings (dict, optional): Settings dictionary to use. If None, uses request-scoped settings. + + Returns: + bool: True when idle-timeout enforcement should run; otherwise False. + + Raises: + None: Unexpected values are coerced to boolean-compatible behavior. + """ + if settings is None: + settings = get_request_settings() + + enabled_raw = settings.get('enable_idle_timeout', False) + + if isinstance(enabled_raw, str): + return enabled_raw.strip().lower() in ('1', 'true', 'yes', 'on') + + return bool(enabled_raw) + + +settings_source_counters = {} +settings_source_counters_lock = threading.Lock() +settings_source_last_observed = None +settings_source_last_non_cache_log_epoch = 0 +settings_source_non_cache_log_interval_seconds = 60 + + +def record_request_settings_source(source): + """ + Record and log the source used to resolve request settings. + + Args: + source (str): Settings source label (for example: cache, cosmos_fallback, cosmos_forced). + + Returns: + None: Updates in-memory counters and request context diagnostics. + + Raises: + None: Counter updates and diagnostics are handled internally. + """ + normalized_source = source or 'unknown' + now_epoch = int(time.time()) + + global settings_source_last_observed + global settings_source_last_non_cache_log_epoch + + with settings_source_counters_lock: + settings_source_counters[normalized_source] = settings_source_counters.get(normalized_source, 0) + 1 + cache_hits = settings_source_counters.get('cache', 0) + cosmos_fallback_hits = settings_source_counters.get('cosmos_fallback', 0) + cosmos_forced_hits = settings_source_counters.get('cosmos_forced', 0) + unknown_hits = settings_source_counters.get('unknown', 0) + + previous_source = settings_source_last_observed + source_changed = normalized_source != previous_source + settings_source_last_observed = normalized_source + + non_cache_log_window_elapsed = ( + now_epoch - settings_source_last_non_cache_log_epoch + ) >= settings_source_non_cache_log_interval_seconds + + should_log_non_cache_info = ( + normalized_source != 'cache' + and (source_changed or non_cache_log_window_elapsed) + ) + + if should_log_non_cache_info: + settings_source_last_non_cache_log_epoch = now_epoch + + g.request_settings_source = normalized_source + debug_print( + f"[SETTINGS SOURCE] path={request.path} source={normalized_source}", + category="SETTINGS", + cache_hits=cache_hits, + cosmos_fallback_hits=cosmos_fallback_hits, + cosmos_forced_hits=cosmos_forced_hits, + unknown_hits=unknown_hits + ) + + if should_log_non_cache_info: + log_event( + "Request settings source is non-cache.", + extra={ + "path": request.path, + "settings_source": normalized_source, + "source_changed": source_changed, + "non_cache_log_window_elapsed": non_cache_log_window_elapsed, + "cache_hits": cache_hits, + "cosmos_fallback_hits": cosmos_fallback_hits, + "cosmos_forced_hits": cosmos_forced_hits, + "unknown_hits": unknown_hits + }, + level=logging.INFO + ) + + +def get_request_settings(): + """ + Get request-scoped settings, resolving and caching them when needed. + + Args: + None + + Returns: + dict: Request settings dictionary cached on Flask `g` for the current request. + + Raises: + None: Unexpected resolver response shapes are logged and handled with safe fallbacks. + """ + request_settings = getattr(g, 'request_settings', None) + if request_settings is None: + settings_result = get_settings(include_source=True) + if isinstance(settings_result, tuple) and len(settings_result) == 2: + request_settings, settings_source = settings_result + else: + request_settings = settings_result + settings_source = 'unknown' + log_event( + "Unexpected settings response shape in get_request_settings.", + extra={ + "path": request.path, + "response_type": type(settings_result).__name__ + }, + level=logging.WARNING + ) + + request_settings = request_settings or {} + g.request_settings = request_settings + record_request_settings_source(settings_source) + return request_settings + @app.context_processor def inject_settings(): - settings = get_settings() + settings = get_request_settings() public_settings = sanitize_settings_for_user(settings) + idle_timeout_enabled = is_idle_timeout_enabled(settings) + idle_timeout_minutes, idle_warning_minutes = get_idle_timeout_settings(settings) # Inject per-user settings if logged in user_settings = {} try: @@ -326,7 +554,13 @@ def inject_settings(): print(f"Error injecting user settings: {e}") log_event(f"Error injecting user settings: {e}", level=logging.ERROR) user_settings = {} - return dict(app_settings=public_settings, user_settings=user_settings) + return dict( + app_settings=public_settings, + user_settings=user_settings, + idle_timeout_enabled=idle_timeout_enabled, + idle_timeout_minutes=idle_timeout_minutes, + idle_warning_minutes=idle_warning_minutes + ) @app.template_filter('to_datetime') def to_datetime_filter(value): @@ -350,6 +584,121 @@ def reload_kernel_if_needed(): """ setattr(builtins, "kernel_reload_needed", False) + +def _is_idle_timeout_exempt(path): + """ + Check whether a request path is exempt from idle-timeout processing. + + Args: + path (str): Request path to evaluate. + + Returns: + bool: True if the path is exempt from idle-timeout checks; otherwise False. + + Raises: + None + """ + if path in IDLE_TIMEOUT_EXEMPT_PATHS: + return True + return any(path.startswith(prefix) for prefix in IDLE_TIMEOUT_EXEMPT_PREFIXES) + +@app.before_request +def enforce_idle_session_timeout(): + """ + Enforce server-side idle session timeout for authenticated requests. + + Args: + None + + Returns: + Response | None: A redirect/401 response when timeout is exceeded; otherwise None. + + Raises: + None: Runtime issues in timeout evaluation are logged and request processing continues safely. + """ + if 'user' not in session: + return None + + if request.method == 'OPTIONS' or _is_idle_timeout_exempt(request.path): + return None + + now_epoch = int(time.time()) + request_settings = get_request_settings() + if not is_idle_timeout_enabled(request_settings): + disabled_refresh_interval_seconds = 60 + last_activity_epoch = session.get('last_activity_epoch') + should_refresh_last_activity = False + + if last_activity_epoch is None: + should_refresh_last_activity = True + else: + try: + parsed_last_activity_epoch = int(float(last_activity_epoch)) + if ( + parsed_last_activity_epoch > now_epoch + or (now_epoch - parsed_last_activity_epoch) >= disabled_refresh_interval_seconds + ): + should_refresh_last_activity = True + except (TypeError, ValueError): + should_refresh_last_activity = True + + if should_refresh_last_activity: + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + + idle_timeout_minutes, _ = get_idle_timeout_settings(request_settings) + last_activity_epoch = session.get('last_activity_epoch') + has_valid_last_activity_epoch = False + max_allowed_future_skew_seconds = 60 + + if last_activity_epoch is not None: + try: + parsed_last_activity_epoch = int(float(last_activity_epoch)) + if parsed_last_activity_epoch <= (now_epoch + max_allowed_future_skew_seconds): + has_valid_last_activity_epoch = True + else: + log_event( + "Idle timeout last_activity_epoch is in the future; resetting timestamp.", + extra={ + "path": request.path, + "parsed_last_activity_epoch": parsed_last_activity_epoch, + "now_epoch": now_epoch, + "max_allowed_future_skew_seconds": max_allowed_future_skew_seconds + }, + level=logging.WARNING + ) + idle_seconds = now_epoch - parsed_last_activity_epoch + if idle_seconds >= (idle_timeout_minutes * 60): + user_id = session.get('user', {}).get('oid') or session.get('user', {}).get('sub') + session.clear() + + log_event( + f"Session expired due to {idle_timeout_minutes} minute inactivity timeout for user {user_id or 'unknown'}.", + level=logging.INFO + ) + + if request.path.startswith('/api/'): + return jsonify({ + 'error': 'Session expired', + 'message': 'Your session expired due to inactivity. Please sign in again.', + 'requires_reauth': True + }), 401 + + return redirect(url_for('local_logout')) + except Exception as e: + log_event(f"Idle timeout evaluation failed: {e}", level=logging.WARNING) + + if request.path.startswith('/api/'): + if not has_valid_last_activity_epoch: + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + @app.after_request def add_security_headers(response): """ @@ -442,6 +791,30 @@ def serve_js_modules(filename): def acceptable_use_policy(): return render_template('acceptable_use_policy.html') +@app.route('/api/session/heartbeat', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +def session_heartbeat(): + """ + Refresh the authenticated session activity timestamp used by idle-timeout enforcement. + + Args: + None + + Returns: + tuple[Response, int]: JSON response containing refresh confirmation and timeout metadata. + + Raises: + None + """ + session['last_activity_epoch'] = int(time.time()) + session.modified = True + idle_timeout_minutes, _ = get_idle_timeout_settings(get_request_settings()) + return jsonify({ + 'message': 'Session refreshed', + 'idle_timeout_minutes': idle_timeout_minutes + }), 200 + @app.route('/api/semantic-kernel/plugins') @swagger_route(security=get_auth_security()) def list_semantic_kernel_plugins(): diff --git a/application/single_app/config.py b/application/single_app/config.py index 059e8706..2170264b 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.150" +VERSION = "0.240.002" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -185,6 +185,23 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): CUSTOM_OIDC_METADATA_URL_VALUE = os.getenv("CUSTOM_OIDC_METADATA_URL_VALUE", "") +# Optional User Idle Timeout Configuration +IDLE_TIMEOUT_EXEMPT_PATHS = { + '/login', + '/logout', + '/logout/local', + '/getAToken', + '/getATokenApi', + '/robots933456.txt', + '/favicon.ico' +} + +IDLE_TIMEOUT_EXEMPT_PREFIXES = ( + '/static/', + '/health', + '/api/health' +) + # Azure AD Configuration CLIENT_ID = os.getenv("CLIENT_ID") APP_URI = f"api://{CLIENT_ID}" @@ -575,7 +592,7 @@ def ensure_custom_favicon_file_exists(app, settings): except (base64.binascii.Error, TypeError, OSError) as ex: # Catch specific errors print(f"Failed to write/overwrite {favicon_filename}: {ex}") except Exception as ex: # Catch any other unexpected errors - print(f"Unexpected error during favicon file write for {favicon_filename}: {ex}") + print(f"Unexpected error during favicon file write for {favicon_filename}: {ex}") def initialize_clients(settings): """ diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index a282f409..9ecb7add 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -4,8 +4,9 @@ from functions_appinsights import log_event import app_settings_cache import inspect +import copy -def get_settings(use_cosmos=False): +def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { # External health check @@ -264,6 +265,10 @@ def get_settings(use_cosmos=False): 'max_file_size_mb': 150, 'tabular_preview_max_blob_size_mb': 200, 'conversation_history_limit': 10, + 'enable_idle_timeout': False, + 'idle_timeout_minutes': 30, + 'idle_warning_minutes': 28, + 'idle_warning_message': "You've been inactive for a while.", 'default_system_prompt': '', # Access denied message shown on the home page for signed-in users who lack required roles. # Default is hard-coded; admins can override via Admin Settings (persisted in Cosmos DB). @@ -331,6 +336,11 @@ def get_settings(use_cosmos=False): 'default_retention_document_public': 'none', } + def _format_result(settings_payload, source): + if include_source: + return settings_payload, source + return settings_payload + try: # Attempt to read the existing doc if use_cosmos: @@ -338,17 +348,35 @@ def get_settings(use_cosmos=False): item="app_settings", partition_key="app_settings" ) + settings_source = "cosmos_forced" + log_event( + "App settings loaded from Cosmos DB (forced).", + extra={ + "settings_source": settings_source, + "use_cosmos": True + }, + level=logging.INFO + ) else: settings_item = None + settings_source = "cache" cache_accessor = getattr(app_settings_cache, "get_settings_cache", None) if callable(cache_accessor): try: settings_item = cache_accessor() - except Exception: + except Exception as cache_error: settings_item = None + log_event( + "Error reading app settings from cache accessor.", + extra={ + "error": str(cache_error) + }, + level=logging.WARNING + ) if not settings_item: + settings_source = "cosmos_fallback" settings_item = cosmos_settings_container.read_item( item="app_settings", partition_key="app_settings" @@ -362,37 +390,91 @@ def get_settings(use_cosmos=False): caller_file = code.co_filename caller_line = caller.f_lineno caller_func = code.co_name - print( - "Warning: Failed to get settings from cache, read from Cosmos DB instead. " - f"Called from {caller_file}:{caller_line} in {caller_func}()." + # print( + # "Warning: Failed to get settings from cache, read from Cosmos DB instead. " + # f"Called from {caller_file}:{caller_line} in {caller_func}()." + # ) + log_event( + "App settings cache miss. Falling back to Cosmos DB.", + extra={ + "settings_source": settings_source, + "caller_file": caller_file, + "caller_line": caller_line, + "caller_func": caller_func + }, + level=logging.WARNING ) else: - print( - "Warning: Failed to get settings from cache, " - "read from Cosmos DB instead. (no caller frame)" + # print( + # "Warning: Failed to get settings from cache, " + # "read from Cosmos DB instead. (no caller frame)" + # ) + log_event( + "App settings cache miss. Falling back to Cosmos DB (no caller frame).", + extra={ + "settings_source": settings_source + }, + level=logging.WARNING ) #print("Successfully retrieved settings from Cosmos DB.") # Merge default_settings in, to fill in any missing or nested keys - merged = deep_merge_dicts(default_settings, settings_item) + merged = settings_item + settings_changed = deep_merge_dicts(default_settings, merged) # If merging added anything new, upsert back to Cosmos so future reads remain up to date - if merged != settings_item: + if settings_changed: cosmos_settings_container.upsert_item(merged) - print("App Settings had missing keys and was updated in Cosmos DB.") - return merged + cache_updater = getattr(app_settings_cache, "update_settings_cache", None) + if callable(cache_updater): + try: + cache_updater(copy.deepcopy(merged)) + except Exception as cache_error: + log_event( + "App settings cache update failed after merge upsert.", + extra={ + "settings_source": settings_source, + "error": str(cache_error) + }, + level=logging.WARNING + ) + # print("App Settings had missing keys and was updated in Cosmos DB.") + log_event( + "App settings missing keys were merged and persisted to Cosmos DB.", + extra={ + "settings_source": settings_source + }, + level=logging.INFO + ) + return _format_result(merged, settings_source) else: # If merged is unchanged, no new keys needed - return merged + return _format_result(merged, settings_source) except CosmosResourceNotFoundError: cosmos_settings_container.create_item(body=default_settings) - print("Default settings created in Cosmos and returned.") - return default_settings + # print("Default settings created in Cosmos and returned.") + log_event( + "App settings document not found. Default settings created in Cosmos DB.", + extra={ + "settings_source": "cosmos_default_created" + }, + level=logging.WARNING + ) + return _format_result(default_settings, "cosmos_default_created") except Exception as e: - print(f"Error retrieving settings: {str(e)}") - return None + # print(f"Error retrieving settings: {str(e)}") + log_event( + "Error retrieving app settings.", + extra={ + "error": str(e), + "use_cosmos": use_cosmos + }, + level=logging.ERROR, + exceptionTraceback=True + ) + return _format_result(None, "error") def update_settings(new_settings): try: @@ -406,10 +488,20 @@ def update_settings(new_settings): cache_updater = getattr(app_settings_cache, "update_settings_cache", None) if callable(cache_updater): cache_updater(settings_item) - print("Settings updated successfully.") + log_event( + "App settings updated successfully.", + level=logging.INFO + ) return True except Exception as e: - print(f"Error updating settings: {str(e)}") + log_event( + "Error updating app settings.", + extra={ + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return False def compare_versions(v1_str, v2_str): @@ -436,7 +528,14 @@ def compare_versions(v1_str, v2_str): v2_parts = [int(part) for part in v2_str.split('.')] except ValueError: # Handle cases where parts are not integers or contain invalid chars - print(f"Invalid version format encountered: '{v1_str}' or '{v2_str}'") + log_event( + "Invalid version format encountered during comparison.", + extra={ + "version_1": v1_str, + "version_2": v2_str + }, + level=logging.WARNING + ) return None # Compare parts element by element @@ -468,7 +567,10 @@ def extract_latest_version_from_html(html_content): valid versions are found or an error occurs. """ if not html_content: - print("HTML content is empty.") + log_event( + "Latest-version extraction skipped because HTML content is empty.", + level=logging.WARNING + ) return None try: @@ -501,7 +603,10 @@ def extract_latest_version_from_html(html_content): continue # Skip to the next link if not versions_found: - print("No valid version tags found in HTML matching the pattern.") + log_event( + "No valid release version tags found in HTML content.", + level=logging.WARNING + ) return None # Now compare the found versions to find the latest @@ -519,28 +624,65 @@ def extract_latest_version_from_html(html_content): latest_version = current_version elif comparison_result is None: # Log if comparison fails, but continue trying others - print(f"Warning: Could not compare version '{current_version}' with '{latest_version}'. Skipping this comparison.") + log_event( + "Could not compare release version values while scanning HTML.", + extra={ + "current_version": current_version, + "latest_version": latest_version + }, + level=logging.WARNING + ) # else: comparison is -1 or 0, keep existing latest_version # print(f" -> '{latest_version}' remains latest.") - print(f"Latest version identified from HTML: {latest_version}") + log_event( + "Latest release version identified from HTML.", + extra={ + "latest_version": latest_version + }, + level=logging.INFO + ) return latest_version except Exception as e: - print(f"Error parsing HTML or finding latest version: {e}") + log_event( + "Error parsing HTML while identifying latest release version.", + extra={ + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return None def deep_merge_dicts(default_dict, existing_dict): + """ + Recursively merge keys from default_dict into existing_dict in place. + This function DOES NOT return a merged dictionary. Instead, it mutates + existing_dict directly, adding any keys that are missing (and, for nested + dict values, recursing to merge their contents as well). Non-dict values + in existing_dict are left as-is and are not overwritten. + + Args: + default_dict (dict): Source of default values. + existing_dict (dict): Target dictionary that will be updated in place. + + Returns: + bool: True if existing_dict was modified at any depth, otherwise False. + """ + changed = False for k, default_val in default_dict.items(): if k not in existing_dict: existing_dict[k] = default_val + changed = True else: existing_val = existing_dict[k] if isinstance(default_val, dict) and isinstance(existing_val, dict): - deep_merge_dicts(default_val, existing_val) + if deep_merge_dicts(default_val, existing_val): + changed = True # For lists or other types, we skip overwriting. - return existing_dict + return changed def encrypt_key(key): cipher_suite = Fernet(app.config['SECRET_KEY']) @@ -554,7 +696,10 @@ def decrypt_key(encrypted_key): decrypted_key = cipher_suite.decrypt(encrypted_key_bytes).decode() return decrypted_key except InvalidToken: - print("Decryption failed: Invalid token") + log_event( + "Decryption failed due to invalid token.", + level=logging.WARNING + ) return None def get_user_settings(user_id): @@ -593,7 +738,14 @@ def get_user_settings(user_id): doc['settings']['profileImage'] = profile_image updated = True except Exception as e: - print(f"Warning: Could not fetch profile image for user {user_id}: {e}") + log_event( + "Could not fetch profile image for existing user.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.WARNING + ) doc['settings']['profileImage'] = None updated = True @@ -617,13 +769,28 @@ def get_user_settings(user_id): profile_image = get_user_profile_image() doc['settings']['profileImage'] = profile_image except Exception as e: - print(f"Warning: Could not fetch profile image for new user {user_id}: {e}") + log_event( + "Could not fetch profile image for new user.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.WARNING + ) doc['settings']['profileImage'] = None cosmos_user_settings_container.upsert_item(body=doc) return doc except Exception as e: - print(f"Error in get_user_settings for {user_id}: {e}") + log_event( + "Error retrieving user settings.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) raise # Re-raise the exception to be handled by the route def update_user_settings(user_id, settings_to_update): @@ -639,7 +806,6 @@ def update_user_settings(user_id, settings_to_update): Returns: bool: True if the update was successful, False otherwise. """ - log_prefix = f"User settings update for {user_id}:" sanitized_settings_to_update = sanitize_settings_for_logging(settings_to_update) log_event("[UserSettings] Update Attempt", {"user_id": user_id, "settings_to_update": sanitized_settings_to_update}) @@ -773,12 +939,28 @@ def update_user_settings(user_id, settings_to_update): return True except exceptions.CosmosHttpResponseError as e: - print(f"{log_prefix} Cosmos DB HTTP error: {e}") + log_event( + "User settings update failed with Cosmos DB HTTP error.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return False except Exception as e: # Catch any other unexpected errors during the update process - print(f"{log_prefix} Unexpected error during update: {e}") + log_event( + "User settings update failed with unexpected error.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return False @@ -866,7 +1048,15 @@ def get_user_search_history(user_id): except exceptions.CosmosResourceNotFoundError: return [] except Exception as e: - print(f"Error getting search history: {e}") + log_event( + "Error retrieving user search history.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return [] def add_search_to_history(user_id, search_term): @@ -896,7 +1086,15 @@ def add_search_to_history(user_id, search_term): return search_history except Exception as e: - print(f"Error adding search to history: {e}") + log_event( + "Error adding search term to user history.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return [] def clear_user_search_history(user_id): @@ -912,5 +1110,13 @@ def clear_user_search_history(user_id): return True except Exception as e: - print(f"Error clearing search history: {e}") + log_event( + "Error clearing user search history.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return False \ No newline at end of file diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index b9e69c51..be879bcb 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -8,6 +8,7 @@ from functions_logging import * from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta +from admin_settings_int_utils import safe_int_with_source ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG') @@ -219,6 +220,16 @@ def admin_settings(): if 'multimodal_vision_model' not in settings: settings['multimodal_vision_model'] = '' + # --- Add defaults for user idle timeout --- + if 'enable_idle_timeout' not in settings: + settings['enable_idle_timeout'] = False + if 'idle_timeout_minutes' not in settings: + settings['idle_timeout_minutes'] = 30 + if 'idle_warning_minutes' not in settings: + settings['idle_warning_minutes'] = 28 + if 'idle_warning_message' not in settings: + settings['idle_warning_message'] = "You've been inactive for a while." + if request.method == 'GET': # --- Model fetching logic remains the same --- gpt_deployments = [] @@ -304,10 +315,64 @@ def admin_settings(): form_data = request.form # Use a variable for easier access user_id = get_current_user_id() + def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_default=0): + """ + Parse an admin form value to an integer with structured fallback diagnostics. + + Args: + raw_value (object): The submitted form value to parse. + fallback_value (object): The fallback value to parse when input conversion fails. + field_name (str): The admin settings field name being parsed. + hard_default (int): Final integer default when both input and fallback are invalid. + + Returns: + int: A valid integer derived from input, fallback, or hard default. + Raises: + None. + """ + parsed_value, parse_source = safe_int_with_source(raw_value, fallback_value, hard_default) + + if parse_source == "hard_default": + log_event( + "Invalid admin settings integer input and fallback detected; using hard default value.", + extra={ + "field": field_name, + "raw_value": str(raw_value), + "fallback_value": str(fallback_value), + "hard_default": hard_default, + "user_id": user_id + }, + level=logging.WARNING + ) + elif parse_source == "fallback": + log_event( + "Invalid admin settings integer input detected; using fallback value.", + extra={ + "field": field_name, + "raw_value": str(raw_value), + "fallback_value": str(fallback_value), + "user_id": user_id + }, + level=logging.WARNING + ) + + return parsed_value + # --- Fetch all other form data as before --- app_title = form_data.get('app_title', 'AI Chat Application') max_file_size_mb = int(form_data.get('max_file_size_mb', 16)) conversation_history_limit = int(form_data.get('conversation_history_limit', 10)) + enable_idle_timeout = form_data.get('enable_idle_timeout') == 'on' + idle_timeout_minutes = max(10, parse_admin_int(form_data.get('idle_timeout_minutes'), settings.get('idle_timeout_minutes', 30), 'idle_timeout_minutes', 30)) + idle_warning_minutes = max(0, parse_admin_int(form_data.get('idle_warning_minutes'), settings.get('idle_warning_minutes', 28), 'idle_warning_minutes', 28)) + idle_warning_message = form_data.get( + 'idle_warning_message', + settings.get('idle_warning_message', "You've been inactive for a while.") + ).strip() + if idle_warning_minutes > idle_timeout_minutes: + idle_warning_minutes = idle_timeout_minutes + if not idle_warning_message: + idle_warning_message = "You've been inactive for a while." # ... (fetch all other fields using form_data.get) ... enable_video_file_support = form_data.get('enable_video_file_support') == 'on' enable_audio_file_support = form_data.get('enable_audio_file_support') == 'on' @@ -886,6 +951,10 @@ def is_valid_url(url): # Other 'max_file_size_mb': max_file_size_mb, 'conversation_history_limit': conversation_history_limit, + 'enable_idle_timeout': enable_idle_timeout, + 'idle_timeout_minutes': idle_timeout_minutes, + 'idle_warning_minutes': idle_warning_minutes, + 'idle_warning_message': idle_warning_message, 'default_system_prompt': form_data.get('default_system_prompt', '').strip(), 'access_denied_message': form_data.get('access_denied_message', settings.get('access_denied_message', '')).strip(), diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 022ecf84..a7f8e2a6 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -35,13 +35,14 @@ def login(): # Clear potentially stale cache/user info before starting new login session.pop("user", None) session.pop("token_cache", None) + session.pop("last_activity_epoch", None) # Use helper to build app (cache not strictly needed here, but consistent) msal_app = _build_msal_app() # Get settings from database, with environment variable fallback from functions_settings import get_settings - settings = get_settings() + settings = get_settings() or {} # Only use Front Door redirect URL if Front Door is enabled if settings.get('enable_front_door', False): @@ -88,7 +89,7 @@ def authorized(): # Get settings from database, with environment variable fallback from functions_settings import get_settings - settings = get_settings() + settings = get_settings() or {} # Only use Front Door redirect URL if Front Door is enabled if settings.get('enable_front_door', False): @@ -121,6 +122,7 @@ def authorized(): debug_print(f" [claims] User claims: {result.get('id_token_claims', {})}") session["user"] = result.get("id_token_claims") + session["last_activity_epoch"] = int(time.time()) # --- CRITICAL: Save the entire cache (contains tokens) to session --- _save_cache(msal_app.token_cache) @@ -140,7 +142,7 @@ def authorized(): # You might want to store the original destination in the session during /login # Get settings from database, with environment variable fallback from functions_settings import get_settings - settings = get_settings() + settings = get_settings() or {} debug_print(f"HOME_REDIRECT_URL (env): {HOME_REDIRECT_URL}") debug_print(f"front_door_url (db): {settings.get('front_door_url')}") @@ -182,7 +184,7 @@ def authorized_api(): # Get settings for redirect URI (same logic as other routes) from functions_settings import get_settings - settings = get_settings() + settings = get_settings() or {} if settings.get('enable_front_door', False): front_door_url = settings.get('front_door_url') @@ -207,6 +209,39 @@ def authorized_api(): return jsonify(result, 200) + @app.route('/logout/local') + @swagger_route(security=get_auth_security()) + def local_logout(): + """ + Clear the local Flask session and redirect to the configured home destination. + + Args: + None. + + Returns: + Response: A redirect response to the local or Front Door home URL. + Raises: + None. + """ + session.clear() + + from functions_settings import get_settings + settings = get_settings() or {} + + if settings.get('enable_front_door', False): + front_door_url = settings.get('front_door_url') + if front_door_url: + home_url, _ = build_front_door_urls(front_door_url) + logout_uri = home_url + elif HOME_REDIRECT_URL: + logout_uri = HOME_REDIRECT_URL + else: + logout_uri = url_for('index') + else: + logout_uri = url_for('index') + + return redirect(logout_uri) + @app.route('/logout') @swagger_route(security=get_auth_security()) def logout(): @@ -219,7 +254,7 @@ def logout(): # MSAL provides a helper for this too, but constructing manually is fine # Get settings from database, with environment variable fallback from functions_settings import get_settings - settings = get_settings() + settings = get_settings() or {} # Only use Front Door redirect URL if Front Door is enabled if settings.get('enable_front_door', False): @@ -231,13 +266,13 @@ def logout(): # Fall back to environment variable if Front Door is enabled but no URL is set logout_uri = HOME_REDIRECT_URL else: - logout_uri = url_for('index', _external=True, _scheme='https') + logout_uri = url_for('index', _external=True) else: - logout_uri = url_for('index', _external=True, _scheme='https') + logout_uri = url_for('index', _external=True) - print(f"Front Door enabled: {settings.get('enable_front_door', False)}") - print(f"Front Door URL: {settings.get('front_door_url')}") - print(f"Logout redirect URI: {logout_uri}") + # print(f"Front Door enabled: {settings.get('enable_front_door', False)}") + # print(f"Front Door URL: {settings.get('front_door_url')}") + # print(f"Logout redirect URI: {logout_uri}") logout_url = ( f"{AUTHORITY}/oauth2/v2.0/logout" @@ -247,5 +282,5 @@ def logout(): if user_email: logout_url += f"&logout_hint={quote(user_email)}" - print(f"{user_name} logged out. Redirecting to Azure AD logout.") + # print(f"{user_name} logged out. Redirecting to Azure AD logout.") return redirect(logout_url) \ No newline at end of file diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 8bdd0711..f0cdb41e 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1260,7 +1260,7 @@ a.citation-link:hover { .message-content { display: flex; align-items: flex-end; - overflow: auto; /* Preserving higher level visible property while allowing response message scroll if needed */ + overflow: auto; /* Make message content scrollable when it overflows while minimizing effects elsewhere. */ } .message-content.flex-row-reverse { diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png deleted file mode 100644 index ecf6e652..00000000 Binary files a/application/single_app/static/images/custom_logo.png and /dev/null differ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png deleted file mode 100644 index 4f281945..00000000 Binary files a/application/single_app/static/images/custom_logo_dark.png and /dev/null differ diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 21c989fd..425a4b4f 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1550,6 +1550,16 @@ function setupToggles() { }); } + const enableIdleTimeoutToggle = document.getElementById('enable_idle_timeout'); + const idleTimeoutSettingsDiv = document.getElementById('idle_timeout_settings'); + if (enableIdleTimeoutToggle && idleTimeoutSettingsDiv) { + idleTimeoutSettingsDiv.classList.toggle('d-none', !enableIdleTimeoutToggle.checked); + enableIdleTimeoutToggle.addEventListener('change', function () { + idleTimeoutSettingsDiv.classList.toggle('d-none', !this.checked); + markFormAsModified(); + }); + } + const enableEnhancedCitation = document.getElementById('enable_enhanced_citations'); if (enableEnhancedCitation) { toggleEnhancedCitation(enableEnhancedCitation.checked); diff --git a/application/single_app/static/js/idle-logout-warning.js b/application/single_app/static/js/idle-logout-warning.js new file mode 100644 index 00000000..ea187ae2 --- /dev/null +++ b/application/single_app/static/js/idle-logout-warning.js @@ -0,0 +1,258 @@ +// idle-logout-warning.js + +(function () { + 'use strict'; + + const defaultConfig = { + enabled: false, + timeoutMinutes: 30, + warningMinutes: 28, + heartbeatUrl: '/api/session/heartbeat', + localLogoutUrl: '/logout/local', + fullSsoLogoutUrl: '/logout', + logoutUrl: '/logout' + }; + + const mergedConfig = Object.assign({}, defaultConfig, window.idleLogoutConfig || {}); + + if (!mergedConfig.enabled) { + return; + } + + document.addEventListener('DOMContentLoaded', function () { + if (typeof bootstrap === 'undefined' || !bootstrap.Modal) { + return; + } + + const warningModalElement = document.getElementById('idleTimeoutWarningModal'); + const countdownElement = document.getElementById('idleTimeoutCountdown'); + const staySignedInButton = document.getElementById('idleStaySignedInButton'); + const logoutNowButton = document.getElementById('idleLogoutNowButton'); + + if (!warningModalElement || !countdownElement || !staySignedInButton || !logoutNowButton) { + return; + } + + const timeoutMinutes = Number(mergedConfig.timeoutMinutes); + const warningMinutes = Number(mergedConfig.warningMinutes); + + if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { + return; + } + + if (!Number.isFinite(warningMinutes) || warningMinutes < 0 || warningMinutes > timeoutMinutes) { + return; + } + + const timeoutMs = timeoutMinutes * 60 * 1000; + const warningMs = warningMinutes * 60 * 1000; + const warningDisabled = warningMinutes === timeoutMinutes; + const warningModal = bootstrap.Modal.getOrCreateInstance(warningModalElement); + + let warningTimer = null; + let logoutTimer = null; + let countdownInterval = null; + let logoutDeadlineMs = null; + let isRefreshingSession = false; + let pendingUserInitiatedRefresh = false; + let lastActivityResetAt = 0; + let lastServerHeartbeatAt = 0; + + const HEARTBEAT_MIN_INTERVAL_MS = Math.min(60000, timeoutMs / 2); + + const activityEvents = [ + 'mousedown', + 'mousemove', + 'keydown', + 'scroll', + 'touchstart', + 'click' + ]; + + function clearTimers() { + if (warningTimer) { + clearTimeout(warningTimer); + warningTimer = null; + } + + if (logoutTimer) { + clearTimeout(logoutTimer); + logoutTimer = null; + } + } + + function stopCountdown() { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + } + + function updateCountdown() { + if (!logoutDeadlineMs) { + return; + } + + const remainingMs = Math.max(0, logoutDeadlineMs - Date.now()); + const remainingSeconds = Math.ceil(remainingMs / 1000); + countdownElement.textContent = String(remainingSeconds); + } + + function startCountdown() { + stopCountdown(); + updateCountdown(); + countdownInterval = setInterval(updateCountdown, 1000); + } + + function hideWarningModal() { + if (warningModalElement.classList.contains('show')) { + warningModal.hide(); + } + stopCountdown(); + } + + function logoutNow() { + clearTimers(); + stopCountdown(); + const logoutTarget = mergedConfig.localLogoutUrl || mergedConfig.fullSsoLogoutUrl || mergedConfig.logoutUrl; + window.location.href = logoutTarget; + } + + function scheduleIdleTimers() { + clearTimers(); + logoutDeadlineMs = Date.now() + timeoutMs; + + if (!warningDisabled) { + warningTimer = setTimeout(function () { + warningModal.show(); + startCountdown(); + }, warningMs); + } else { + hideWarningModal(); + } + + logoutTimer = setTimeout(logoutNow, timeoutMs); + } + + async function refreshServerSession(forceLogoutOnFailure, userInitiated) { + if (isRefreshingSession) { + if (userInitiated) { + pendingUserInitiatedRefresh = true; + staySignedInButton.disabled = true; + } + return; + } + + isRefreshingSession = true; + const isUserInitiatedRefresh = Boolean(userInitiated); + if (isUserInitiatedRefresh) { + staySignedInButton.disabled = true; + } + + try { + const response = await fetch(mergedConfig.heartbeatUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: '{}' + }); + + if (!response.ok) { + let requiresReauth = response.status === 401 || response.status === 403; + + if (!requiresReauth) { + try { + const responseBody = await response.clone().json(); + requiresReauth = Boolean(responseBody && responseBody.requires_reauth); + } catch (_parseError) { + requiresReauth = false; + } + } + + if (forceLogoutOnFailure || requiresReauth) { + logoutNow(); + } + return; + } + + lastServerHeartbeatAt = Date.now(); + + if (isUserInitiatedRefresh) { + hideWarningModal(); + scheduleIdleTimers(); + } + } catch (error) { + console.error('Session heartbeat failed:', error); + if (forceLogoutOnFailure) { + logoutNow(); + } + } finally { + isRefreshingSession = false; + if (isUserInitiatedRefresh) { + staySignedInButton.disabled = false; + } + + if (pendingUserInitiatedRefresh) { + pendingUserInitiatedRefresh = false; + fireAndForgetSessionRefresh(true, true); + } + } + } + + function fireAndForgetSessionRefresh(forceLogoutOnFailure, userInitiated) { + void refreshServerSession(forceLogoutOnFailure, userInitiated).catch(function (error) { + console.error('Unexpected session refresh error:', error); + if (forceLogoutOnFailure) { + logoutNow(); + } + }); + } + + staySignedInButton.addEventListener('click', function () { + fireAndForgetSessionRefresh(true, true); + }); + + logoutNowButton.addEventListener('click', function () { + logoutNow(); + }); + + warningModalElement.addEventListener('hidden.bs.modal', function () { + stopCountdown(); + }); + + activityEvents.forEach(function (eventName) { + document.addEventListener(eventName, handleUserActivity); + }); + + document.addEventListener('visibilitychange', function () { + if (!document.hidden) { + handleUserActivity(); + } + }); + + scheduleIdleTimers(); + + function handleUserActivity() { + const now = Date.now(); + + if (now - lastActivityResetAt < 1000) { + return; + } + + lastActivityResetAt = now; + + if (warningModalElement.classList.contains('show')) { + hideWarningModal(); + } + + scheduleIdleTimers(); + + if ((now - lastServerHeartbeatAt) >= HEARTBEAT_MIN_INTERVAL_MS) { + fireAndForgetSessionRefresh(false, false); + } + } + }); +})(); diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 76e366f0..7964f503 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -1429,6 +1429,59 @@