diff --git a/app/__init__.py b/app/__init__.py index 7dde884..1088065 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -869,6 +869,15 @@ def _user_to_dict(u): 'last_login': u.last_login.isoformat() if u.last_login else None, } + def _admin_create_user_target(): + data = request.get_json(silent=True) + if not isinstance(data, dict): + return 'invalid-payload' + username = data.get('username') + if not isinstance(username, str): + return 'invalid-payload' + return username.strip() + @app.route('/admin') @admin_required @login_required @@ -887,13 +896,22 @@ def admin_list_users(): @login_required @step_up_required( 'user.create', - lambda: str((request.get_json(silent=True) or {}).get('username') or ''), + _admin_create_user_target, ) def admin_create_user(): - data = request.get_json(silent=True) or {} - username = (data.get('username') or '').strip() - password = data.get('password') or '' - make_admin = bool(data.get('is_admin')) + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid user payload'}), 400 + username = data.get('username') + password = data.get('password') + make_admin = data.get('is_admin', False) + if ( + not isinstance(username, str) + or not isinstance(password, str) + or type(make_admin) is not bool + ): + return jsonify({'error': 'Invalid user payload'}), 400 + username = username.strip() user, error = register_user(username, password) if error: return jsonify({'error': error}), 400 @@ -1001,7 +1019,9 @@ def admin_reset_user_mfa(user_id): WebAuthnCredential, ) - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -1050,7 +1070,9 @@ def admin_get_settings(): @login_required @step_up_required('settings.update', 'global') def admin_set_settings(): - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid settings payload'}), 400 if 'registration_enabled' in data: if type(data['registration_enabled']) is not bool: return jsonify({ diff --git a/app/admin_backup.py b/app/admin_backup.py index 11d18f4..4169e3c 100644 --- a/app/admin_backup.py +++ b/app/admin_backup.py @@ -403,7 +403,9 @@ def cancel_backup_operation(operation_id): @login_required @step_up_required('backup.restore_prepare', lambda operation_id: operation_id) def prepare_restore(operation_id): - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 if data.get('acknowledge_sensitive_restore') is not True: return jsonify({'error': 'Restore acknowledgement is required'}), 400 try: @@ -437,7 +439,9 @@ def prepare_restore(operation_id): def restore_uploaded_backup(operation_id): if _rate_limited('backup_restore', config.RATELIMIT_BACKUP_RESTORE): return jsonify({'error': 'Too many restore attempts'}), 429 - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 if ( data.get('confirm_destructive_restore') is not True or data.get('confirmation_phrase') != 'RESTORE' diff --git a/app/audit_export.py b/app/audit_export.py index 0d2d41c..2046026 100644 --- a/app/audit_export.py +++ b/app/audit_export.py @@ -195,7 +195,9 @@ def generate(): @step_up_required("audit.retention", "global") def update_audit_retention(): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 value = data.get("backup_count") if type(value) is not int or not 1 <= value <= 90: return jsonify({ diff --git a/app/github_auth_routes.py b/app/github_auth_routes.py index 6bbde01..d4f7d09 100644 --- a/app/github_auth_routes.py +++ b/app/github_auth_routes.py @@ -119,7 +119,9 @@ def github_step_up_start(): from .auth_assurance import current_authentication_session from .step_up import account_step_up_intent, StepUpError - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Invalid request'}), 400 token = data.get('intent') try: intent = account_step_up_intent(token, current_authentication_session()) diff --git a/app/ldap_routes.py b/app/ldap_routes.py index 4f8728f..9c910ac 100644 --- a/app/ldap_routes.py +++ b/app/ldap_routes.py @@ -43,6 +43,7 @@ _MAX_LDAP_FORM_BYTES = 4096 _MAX_LDAP_JSON_BYTES = 4096 _MAX_AUTO_PROVISIONED_USERNAME_LENGTH = 80 +_JSON_BODY_TOO_LARGE = object() def _bounded_json(): @@ -51,14 +52,15 @@ def _bounded_json(): request.content_length is not None and request.content_length > _MAX_LDAP_JSON_BYTES ): - return None + return _JSON_BODY_TOO_LARGE try: raw_data = request.get_data(cache=True) except RequestEntityTooLarge: - return None + return _JSON_BODY_TOO_LARGE if len(raw_data) > _MAX_LDAP_JSON_BYTES: - return None - return request.get_json(silent=True) or {} + return _JSON_BODY_TOO_LARGE + data = request.get_json(silent=True) + return data if isinstance(data, dict) else None def _request_body_too_large(): @@ -278,8 +280,10 @@ def ldap_login(): @step_up_required('ldap.link', lambda user_id: user_id) def link_ldap_identity(user_id): data = _bounded_json() - if data is None: + if data is _JSON_BODY_TOO_LARGE: return _request_body_too_large() + if data is None: + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -289,7 +293,10 @@ def link_ldap_identity(user_id): }), 400 if data.get('confirm_username') != target.username: return jsonify({'error': 'Target confirmation does not match'}), 400 - directory_username = str(data.get('directory_username') or '').strip() + directory_username = data.get('directory_username') + if not isinstance(directory_username, str): + return jsonify({'error': 'Directory username is required'}), 400 + directory_username = directory_username.strip() if not directory_username or len(directory_username) > 256: return jsonify({'error': 'Directory username is required'}), 400 if target.ldap_identity is not None: @@ -430,8 +437,10 @@ def ldap_status(): ) def unlink_ldap_identity(user_id, identity_id): data = _bounded_json() - if data is None: + if data is _JSON_BODY_TOO_LARGE: return _request_body_too_large() + if data is None: + return jsonify({'error': 'Invalid request'}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({'error': 'User not found'}), 404 @@ -440,7 +449,9 @@ def unlink_ldap_identity(user_id, identity_id): row = db.session.get(LDAPIdentity, identity_id) if row is None or row.user_id != target.id: return jsonify({'error': 'LDAP identity not found'}), 404 - new_password = data.get('new_password') or '' + new_password = data.get('new_password') + if not isinstance(new_password, str): + return jsonify({'error': 'New password is required'}), 400 if len(new_password) < config.MIN_PASSWORD_LENGTH: return jsonify({ 'error': ( diff --git a/app/oidc_routes.py b/app/oidc_routes.py index 20006cd..660809b 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -457,7 +457,9 @@ def oidc_callback(): @step_up_required('oidc.link', lambda user_id: user_id) def link_oidc_identity(user_id): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 @@ -467,7 +469,10 @@ def link_oidc_identity(user_id): }), 400 if data.get("confirm_username") != target.username: return jsonify({"error": "Target confirmation does not match"}), 400 - subject = str(data.get("subject") or "").strip() + subject = data.get("subject") + if not isinstance(subject, str): + return jsonify({"error": "OIDC subject is required"}), 400 + subject = subject.strip() if not subject or len(subject) > 512: return jsonify({"error": "OIDC subject is required"}), 400 row = OIDCIdentity( @@ -537,7 +542,9 @@ def list_oidc_identities(user_id): ) def unlink_oidc_identity(user_id, identity_id): _require_enabled() - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 diff --git a/app/socket_capacity.py b/app/socket_capacity.py index 6500e69..71c5c99 100644 --- a/app/socket_capacity.py +++ b/app/socket_capacity.py @@ -37,5 +37,11 @@ def release(self, socket_sid): self._by_user.pop(user_id, None) return user_id + def count_for_user(self, user_id): + """Return the number of process-local sockets owned by one user.""" + user_id = int(user_id) + with self._lock: + return len(self._by_user.get(user_id, ())) + socket_capacity = SocketCapacityRegistry() diff --git a/app/socket_events.py b/app/socket_events.py index 79f135e..b7a8dc8 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -398,7 +398,16 @@ def handle_disconnect(): socket_sid = request.sid _cancel_ssh_banner_prompts_for_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) - user = get_user_from_socket(socket_sid) + try: + user = get_user_from_socket(socket_sid) + except Exception as error: + user = None + log_error( + 'Socket owner lookup failed on disconnect', + user_id=owner_id, + sid=socket_sid, + exception_type=type(error).__name__, + ) user_id = user.id if user else owner_id if user_id is not None: @@ -421,10 +430,35 @@ def handle_disconnect(): exception_type=type(error).__name__, ) - SocketSession.query.filter_by(socket_sid=socket_sid).delete() - db.session.commit() - - other_sessions = SocketSession.query.filter_by(user_id=user_id).count() + # The process-local capacity registry is authoritative for this + # single-worker runtime and remains available if persistent socket + # metadata cannot be updated during a database outage. + other_sessions = socket_capacity.count_for_user(user_id) + try: + SocketSession.query.filter_by(socket_sid=socket_sid).delete() + db.session.commit() + other_sessions = SocketSession.query.filter_by( + user_id=user_id + ).count() + except Exception as error: + try: + db.session.rollback() + except Exception as rollback_error: + log_error( + 'Socket metadata rollback failed on disconnect', + user_id=user_id, + sid=socket_sid, + exception_type=type(rollback_error).__name__, + ) + log_error( + 'Socket metadata cleanup failed on disconnect', + user_id=user_id, + sid=socket_sid, + exception_type=type(error).__name__, + ) + # A replacement socket may have connected after the fallback was + # sampled but before the metadata operation failed. + other_sessions = socket_capacity.count_for_user(user_id) if other_sessions == 0: try: diff --git a/static/js/app.js b/static/js/app.js index d4dc6df..d264b87 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2323,14 +2323,13 @@ // Scrollback lines setting const scrollbackInput = document.getElementById('scrollbackInput'); if (scrollbackInput) { - const savedScrollback = localStorage.getItem('terminalScrollback') || '500'; - scrollbackInput.value = savedScrollback; + scrollbackInput.value = String(TerminalManager.getScrollbackLines()); scrollbackInput.addEventListener('change', () => { let val = parseInt(scrollbackInput.value, 10); if (isNaN(val) || val < 50) val = 50; if (val > 10000) val = 10000; scrollbackInput.value = val; - localStorage.setItem('terminalScrollback', String(val)); + window.BrowserPreferences?.set('terminalScrollback', val); // Update all existing terminals Object.keys(TerminalManager.terminals).forEach(key => { TerminalManager.terminals[key].options.scrollback = val; diff --git a/static/js/command-set-manager.js b/static/js/command-set-manager.js index eba24ea..662009b 100644 --- a/static/js/command-set-manager.js +++ b/static/js/command-set-manager.js @@ -202,8 +202,8 @@ window.CommandSetManager = { const detail = document.createElement('span'); detail.textContent = this.t( 'commandSets.stepCount', - `${commandSet.steps.length} command step${commandSet.steps.length === 1 ? '' : 's'}`, - ); + 'Command steps: {count}', + ).replace('{count}', String(commandSet.steps.length)); preview.append(title, detail); }, diff --git a/static/js/i18n.js b/static/js/i18n.js index 7b868d1..92da6e3 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -157,8 +157,11 @@ const translations = { 'commandSets.edit': 'Edit command set', 'commandSets.duplicate': 'Duplicate', 'commandSets.steps': 'Steps', + 'commandSets.step': 'step', + 'commandSets.stepCount': 'Command steps: {count}', 'commandSets.addFreeText': 'Add free text', 'commandSets.freeText': 'Free text', + 'commandSets.emptyInline': 'Empty free-text step', 'commandSets.saveToLibrary': 'Save as library command', 'commandSets.useDefaultParameters': 'Use library parameters', 'commandSets.useSudo': 'Run commands with sudo', @@ -169,6 +172,7 @@ const translations = { 'commandSets.removeStep': 'Remove step', 'commandSets.noSteps': 'Add a library command or free-text step.', 'commandSets.empty': 'No command sets saved.', + 'commandSets.emptySearch': 'No command sets match this search.', 'commandSets.noSelectionHint': 'No commands will run after connecting.', 'commandSets.missingCommand': 'Missing library command', 'commandSets.missingSet': 'Missing command set', @@ -1131,6 +1135,7 @@ const translations = { 'common.delete': 'Delete', 'common.edit': 'Edit', + 'common.actions': 'Actions', 'common.cancel': 'Cancel', 'common.continue': 'Continue', 'common.save': 'Save', @@ -1185,6 +1190,7 @@ const translations = { 'commands.delete': 'Delete', 'commands.save': 'Save', 'commands.cancel': 'Cancel', + 'commands.noCommands': 'No commands found', 'commands.searchPlaceholder': 'Search commands, parameters, or descriptions...', 'commands.currentOs': 'Current OS', 'commands.detecting': 'Detecting...', @@ -1409,8 +1415,11 @@ const translations = { 'commandSets.edit': 'Sửa bộ lệnh', 'commandSets.duplicate': 'Nhân bản', 'commandSets.steps': 'bước', + 'commandSets.step': 'bước', + 'commandSets.stepCount': 'Số bước lệnh: {count}', 'commandSets.addFreeText': 'Thêm văn bản tự do', 'commandSets.freeText': 'Văn bản tự do', + 'commandSets.emptyInline': 'Bước văn bản tự do trống', 'commandSets.saveToLibrary': 'Lưu vào thư viện lệnh', 'commandSets.useDefaultParameters': 'Dùng tham số từ thư viện', 'commandSets.useSudo': 'Chạy lệnh bằng sudo', @@ -1421,6 +1430,7 @@ const translations = { 'commandSets.removeStep': 'Xóa bước', 'commandSets.noSteps': 'Thêm lệnh từ thư viện hoặc bước văn bản tự do.', 'commandSets.empty': 'Chưa lưu bộ lệnh nào.', + 'commandSets.emptySearch': 'Không có bộ lệnh nào khớp với tìm kiếm này.', 'commandSets.noSelectionHint': 'Không có lệnh nào chạy sau khi kết nối.', 'commandSets.missingCommand': 'Thiếu lệnh trong thư viện', 'commandSets.missingSet': 'Thiếu bộ lệnh', @@ -2383,6 +2393,7 @@ const translations = { 'common.delete': 'Xóa', 'common.edit': 'Chỉnh sửa', + 'common.actions': 'Thao tác', 'common.cancel': 'Hủy', 'common.continue': 'Tiếp tục', 'common.save': 'Lưu', @@ -2437,6 +2448,7 @@ const translations = { 'commands.delete': 'Xóa', 'commands.save': 'Lưu', 'commands.cancel': 'Hủy', + 'commands.noCommands': 'Không tìm thấy lệnh', 'commands.searchPlaceholder': 'Tìm kiếm lệnh, tham số hoặc mô tả...', 'commands.currentOs': 'OS hiện tại', 'commands.detecting': 'Đang phát hiện...', @@ -2660,8 +2672,11 @@ const translations = { 'commandSets.edit': 'Befehlssatz bearbeiten', 'commandSets.duplicate': 'Duplizieren', 'commandSets.steps': 'Schritte', + 'commandSets.step': 'Schritt', + 'commandSets.stepCount': 'Befehlsschritte: {count}', 'commandSets.addFreeText': 'Freitext hinzufügen', 'commandSets.freeText': 'Freitext', + 'commandSets.emptyInline': 'Leerer Freitextschritt', 'commandSets.saveToLibrary': 'Als Bibliotheksbefehl speichern', 'commandSets.useDefaultParameters': 'Parameter aus der Bibliothek verwenden', 'commandSets.useSudo': 'Befehle mit sudo ausführen', @@ -2672,6 +2687,7 @@ const translations = { 'commandSets.removeStep': 'Schritt entfernen', 'commandSets.noSteps': 'Füge einen Bibliotheksbefehl oder einen Freitext-Schritt hinzu.', 'commandSets.empty': 'Noch keine Befehlssätze gespeichert.', + 'commandSets.emptySearch': 'Keine Befehlssätze entsprechen dieser Suche.', 'commandSets.noSelectionHint': 'Nach dem Verbinden werden keine Befehle ausgeführt.', 'commandSets.missingCommand': 'Bibliotheksbefehl fehlt', 'commandSets.missingSet': 'Befehlssatz fehlt', @@ -3647,6 +3663,7 @@ const translations = { 'common.delete': 'Löschen', 'common.edit': 'Bearbeiten', + 'common.actions': 'Aktionen', 'common.cancel': 'Abbrechen', 'common.continue': 'Weiter', 'common.save': 'Speichern', @@ -3687,6 +3704,7 @@ const translations = { 'commands.delete': 'Löschen', 'commands.save': 'Speichern', 'commands.cancel': 'Abbrechen', + 'commands.noCommands': 'Keine Befehle gefunden', 'commands.searchPlaceholder': 'Befehle, Parameter oder Beschreibungen durchsuchen...', 'commands.currentOs': 'Aktuelles OS', 'commands.detecting': 'Erkennung läuft...', @@ -3910,8 +3928,11 @@ const translations = { 'commandSets.edit': 'Modifier l’ensemble', 'commandSets.duplicate': 'Dupliquer', 'commandSets.steps': 'étapes', + 'commandSets.step': 'étape', + 'commandSets.stepCount': 'Étapes de commande : {count}', 'commandSets.addFreeText': 'Ajouter du texte libre', 'commandSets.freeText': 'Texte libre', + 'commandSets.emptyInline': 'Étape de texte libre vide', 'commandSets.saveToLibrary': 'Enregistrer dans la bibliothèque', 'commandSets.useDefaultParameters': 'Utiliser les paramètres de la bibliothèque', 'commandSets.useSudo': 'Exécuter les commandes avec sudo', @@ -3922,6 +3943,7 @@ const translations = { 'commandSets.removeStep': 'Supprimer l’étape', 'commandSets.noSteps': 'Ajoutez une commande de la bibliothèque ou une étape en texte libre.', 'commandSets.empty': 'Aucun ensemble de commandes enregistré.', + 'commandSets.emptySearch': 'Aucun ensemble de commandes ne correspond à cette recherche.', 'commandSets.noSelectionHint': 'Aucune commande ne sera exécutée après la connexion.', 'commandSets.missingCommand': 'Commande de bibliothèque manquante', 'commandSets.missingSet': 'Ensemble de commandes manquant', @@ -4906,6 +4928,7 @@ const translations = { 'common.delete': 'Supprimer', 'common.edit': 'Modifier', + 'common.actions': 'Actions', 'common.cancel': 'Annuler', 'common.continue': 'Continuer', 'common.save': 'Enregistrer', @@ -4937,6 +4960,7 @@ const translations = { 'commands.delete': 'Supprimer', 'commands.save': 'Enregistrer', 'commands.cancel': 'Annuler', + 'commands.noCommands': 'Aucune commande trouvée', 'commands.searchPlaceholder': 'Rechercher des commandes, des paramètres ou des descriptions...', 'commands.currentOs': 'OS actuel', 'commands.detecting': 'Détection...', @@ -5160,8 +5184,11 @@ const translations = { 'commandSets.edit': 'Editar conjunto', 'commandSets.duplicate': 'Duplicar', 'commandSets.steps': 'pasos', + 'commandSets.step': 'paso', + 'commandSets.stepCount': 'Pasos de comando: {count}', 'commandSets.addFreeText': 'Añadir texto libre', 'commandSets.freeText': 'Texto libre', + 'commandSets.emptyInline': 'Paso de texto libre vacío', 'commandSets.saveToLibrary': 'Guardar en la biblioteca', 'commandSets.useDefaultParameters': 'Usar parámetros de la biblioteca', 'commandSets.useSudo': 'Ejecutar comandos con sudo', @@ -5172,6 +5199,7 @@ const translations = { 'commandSets.removeStep': 'Eliminar paso', 'commandSets.noSteps': 'Añade un comando de la biblioteca o un paso de texto libre.', 'commandSets.empty': 'No hay conjuntos de comandos guardados.', + 'commandSets.emptySearch': 'Ningún conjunto de comandos coincide con esta búsqueda.', 'commandSets.noSelectionHint': 'No se ejecutarán comandos después de conectar.', 'commandSets.missingCommand': 'Falta el comando de la biblioteca', 'commandSets.missingSet': 'Falta el conjunto de comandos', @@ -6156,6 +6184,7 @@ const translations = { 'common.delete': 'Eliminar', 'common.edit': 'Editar', + 'common.actions': 'Acciones', 'common.cancel': 'Cancelar', 'common.continue': 'Continuar', 'common.save': 'Guardar', @@ -6187,6 +6216,7 @@ const translations = { 'commands.delete': 'Eliminar', 'commands.save': 'Guardar', 'commands.cancel': 'Cancelar', + 'commands.noCommands': 'No se encontraron comandos', 'commands.searchPlaceholder': 'Buscar comandos, parámetros o descripciones...', 'commands.currentOs': 'OS actual', 'commands.detecting': 'Detectando...', @@ -6410,8 +6440,11 @@ const translations = { 'commandSets.edit': '编辑命令集', 'commandSets.duplicate': '复制', 'commandSets.steps': '步骤', + 'commandSets.step': '步骤', + 'commandSets.stepCount': '命令步骤:{count}', 'commandSets.addFreeText': '添加自由文本', 'commandSets.freeText': '自由文本', + 'commandSets.emptyInline': '空的自由文本步骤', 'commandSets.saveToLibrary': '保存为库命令', 'commandSets.useDefaultParameters': '使用命令库参数', 'commandSets.useSudo': '使用 sudo 运行命令', @@ -6422,6 +6455,7 @@ const translations = { 'commandSets.removeStep': '删除步骤', 'commandSets.noSteps': '添加库命令或自由文本步骤。', 'commandSets.empty': '尚未保存命令集。', + 'commandSets.emptySearch': '没有与此搜索匹配的命令集。', 'commandSets.noSelectionHint': '连接后不会运行任何命令。', 'commandSets.missingCommand': '缺少库命令', 'commandSets.missingSet': '缺少命令集', @@ -7397,6 +7431,7 @@ const translations = { 'common.delete': '删除', 'common.edit': '编辑', + 'common.actions': '操作', 'common.cancel': '取消', 'common.continue': '继续', 'common.save': '保存', @@ -7437,6 +7472,7 @@ const translations = { 'commands.delete': '删除', 'commands.save': '保存', 'commands.cancel': '取消', + 'commands.noCommands': '未找到命令', 'commands.searchPlaceholder': '搜索命令、参数或说明...', 'commands.currentOs': '当前系统', 'commands.detecting': '检测中...', @@ -7504,17 +7540,40 @@ const translations = { } }; +const BrowserPreferences = { + get(key, fallback = null) { + try { + const value = window.localStorage?.getItem(key); + return typeof value === 'string' && value ? value : fallback; + } catch { + return fallback; + } + }, + + set(key, value) { + try { + window.localStorage?.setItem(key, String(value)); + return true; + } catch { + return false; + } + } +}; + +const storedLanguage = BrowserPreferences.get('language', 'en'); + const i18n = { - currentLang: localStorage.getItem('language') || 'en', + currentLang: translations[storedLanguage] ? storedLanguage : 'en', t(key) { - return translations[this.currentLang][key] || translations['en'][key] || key; + const activeTranslations = translations[this.currentLang] || translations.en; + return activeTranslations[key] || translations.en[key] || key; }, setLanguage(lang) { if (translations[lang]) { this.currentLang = lang; - localStorage.setItem('language', lang); + BrowserPreferences.set('language', lang); this.updatePageText(); window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } })); @@ -7583,6 +7642,7 @@ const i18n = { } }; +window.BrowserPreferences = BrowserPreferences; window.i18n = i18n; document.addEventListener('DOMContentLoaded', () => { diff --git a/static/js/settings-center.js b/static/js/settings-center.js index 7a888e3..5ff7654 100644 --- a/static/js/settings-center.js +++ b/static/js/settings-center.js @@ -207,13 +207,20 @@ function initScrollback() { const input = document.getElementById('scrollbackInput'); if (!input) { return; } - input.value = localStorage.getItem('terminalScrollback') || '500'; + const storedValue = window.BrowserPreferences?.get( + 'terminalScrollback', + '500', + ) ?? '500'; + let initialValue = Number.parseInt(storedValue, 10); + if (!Number.isFinite(initialValue)) { initialValue = 500; } + initialValue = Math.min(10000, Math.max(50, initialValue)); + input.value = String(initialValue); input.addEventListener('change', () => { let value = Number.parseInt(input.value, 10); if (!Number.isFinite(value) || value < 50) { value = 50; } if (value > 10000) { value = 10000; } input.value = String(value); - localStorage.setItem('terminalScrollback', String(value)); + window.BrowserPreferences?.set('terminalScrollback', value); setPreferenceStatus(t( 'settings.scrollbackSaved', 'Scrollback saved in this browser.' diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index f03314d..d017597 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -92,11 +92,21 @@ const TerminalManager = { return window.innerWidth < 768 || 'ontouchstart' in window; }, + getScrollbackLines() { + const rawValue = window.BrowserPreferences?.get( + 'terminalScrollback', + '500', + ) ?? '500'; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed)) return 500; + return Math.min(10000, Math.max(50, parsed)); + }, + createTerminal(sessionId, terminalKey = null) { const key = terminalKey || sessionId; const monoFont = this.getMonoFont(); const theme = this.buildTheme(); - const scrollbackLines = parseInt(localStorage.getItem('terminalScrollback') || '500', 10); + const scrollbackLines = this.getScrollbackLines(); const terminal = new Terminal({ cursorBlink: true, fontSize: this.getResponsiveFontSize(), diff --git a/templates/admin.html b/templates/admin.html index c25c168..11f6b23 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -448,7 +448,7 @@