From 0ff03110c9edc68edc630b58c917d87078925f42 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:02:14 -0500 Subject: [PATCH] v1.11.2 (#613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add robust OSC 52 clipboard support for tmux/SSH clipboard sync (#587) Upgrade @xterm/addon-clipboard to v0.2.0 and add a custom RobustClipboardProvider that handles browser Clipboard API focus restrictions by deferring writes until the window regains focus. Uses Electron native clipboard when available for reliable access without browser API limitations. Changes: - Create src/lib/clipboard-provider.ts with write-only provider - Update all terminal components (desktop, mobile, docker console) - Add electronClipboard bridge in Electron preload - Add clipboard permission handler in Electron main process * chore: remove translations * feat: add 5-panel and 6-panel split screen layouts (#584) * Temporary merge for 1.11.1 syncing (#543) * fix: remote translations * feat: support OIDC configuration via environment variables (#531) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: support OIDC configuration via environment variables Add support for configuring OIDC authentication through environment variables, enabling containerized deployments without database access: - OIDC_CLIENT_ID - OIDC_CLIENT_SECRET - OIDC_ISSUER_URL - OIDC_AUTHORIZATION_URL - OIDC_TOKEN_URL - OIDC_USERINFO_URL (optional) - OIDC_IDENTIFIER_PATH (optional, default: "sub") - OIDC_NAME_PATH (optional, default: "name") - OIDC_SCOPES (optional, default: "openid email profile") Environment variables take priority over database configuration. Closes Termix-SSH/Support#16 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: add modern DH group KEX algorithms for better compatibility (#530) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: add modern DH group KEX algorithms for better compatibility Add diffie-hellman-group15/16/17/18-sha512 key exchange algorithms which are supported by ssh2 library but were not configured in Termix. These algorithms provide: - Better compatibility with modern SSH servers (FreeBSD, OpenBSD, etc.) - Stronger security with larger DH groups - RFC 8268 compliance Related to Termix-SSH/Support#205 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: clarify that hostname/FQDN is supported in IP address field (#529) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open… * feat: show terminal title in tab name (#579) * Temporary merge for 1.11.1 syncing (#543) * fix: remote translations * feat: support OIDC configuration via environment variables (#531) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: support OIDC configuration via environment variables Add support for configuring OIDC authentication through environment variables, enabling containerized deployments without database access: - OIDC_CLIENT_ID - OIDC_CLIENT_SECRET - OIDC_ISSUER_URL - OIDC_AUTHORIZATION_URL - OIDC_TOKEN_URL - OIDC_USERINFO_URL (optional) - OIDC_IDENTIFIER_PATH (optional, default: "sub") - OIDC_NAME_PATH (optional, default: "name") - OIDC_SCOPES (optional, default: "openid email profile") Environment variables take priority over database configuration. Closes Termix-SSH/Support#16 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: add modern DH group KEX algorithms for better compatibility (#530) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: add modern DH group KEX algorithms for better compatibility Add diffie-hellman-group15/16/17/18-sha512 key exchange algorithms which are supported by ssh2 library but were not configured in Termix. These algorithms provide: - Better compatibility with modern SSH servers (FreeBSD, OpenBSD, etc.) - Stronger security with larger DH groups - RFC 8268 compliance Related to Termix-SSH/Support#205 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: clarify that hostname/FQDN is supported in IP address field (#529) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal dir… * fix: allow OIDC JIT user creation when registration is disabled (#578) * Temporary merge for 1.11.1 syncing (#543) * fix: remote translations * feat: support OIDC configuration via environment variables (#531) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: support OIDC configuration via environment variables Add support for configuring OIDC authentication through environment variables, enabling containerized deployments without database access: - OIDC_CLIENT_ID - OIDC_CLIENT_SECRET - OIDC_ISSUER_URL - OIDC_AUTHORIZATION_URL - OIDC_TOKEN_URL - OIDC_USERINFO_URL (optional) - OIDC_IDENTIFIER_PATH (optional, default: "sub") - OIDC_NAME_PATH (optional, default: "name") - OIDC_SCOPES (optional, default: "openid email profile") Environment variables take priority over database configuration. Closes Termix-SSH/Support#16 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: add modern DH group KEX algorithms for better compatibility (#530) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: add modern DH group KEX algorithms for better compatibility Add diffie-hellman-group15/16/17/18-sha512 key exchange algorithms which are supported by ssh2 library but were not configured in Termix. These algorithms provide: - Better compatibility with modern SSH servers (FreeBSD, OpenBSD, etc.) - Stronger security with larger DH groups - RFC 8268 compliance Related to Termix-SSH/Support#205 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: clarify that hostname/FQDN is supported in IP address field (#529) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL r… * feat: add file manager sorting by name, date, and size (#582) * Temporary merge for 1.11.1 syncing (#543) * fix: remote translations * feat: support OIDC configuration via environment variables (#531) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: support OIDC configuration via environment variables Add support for configuring OIDC authentication through environment variables, enabling containerized deployments without database access: - OIDC_CLIENT_ID - OIDC_CLIENT_SECRET - OIDC_ISSUER_URL - OIDC_AUTHORIZATION_URL - OIDC_TOKEN_URL - OIDC_USERINFO_URL (optional) - OIDC_IDENTIFIER_PATH (optional, default: "sub") - OIDC_NAME_PATH (optional, default: "name") - OIDC_SCOPES (optional, default: "openid email profile") Environment variables take priority over database configuration. Closes Termix-SSH/Support#16 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: add modern DH group KEX algorithms for better compatibility (#530) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to open terminal directly (#156) (#503) * fix: resolve merge conflict artifacts in dev-1.10.1 - Fix missing closing tags in AppView.tsx NetworkGraphView - Fix incomplete catch blocks in server-stats.ts and db/index.ts - Fix missing closing brace in en.json ports section - Fix HostManagerApp.tsx import path - Fix stats-widgets.ts type definition - Fix schema.ts networkTopology table definition - Add type annotations in user-data-import.ts * feat: support URL routes to open terminal directly (#156) - Add /terminal/{hostNameOrId} route for new format - Keep /hosts/{id}/terminal for backward compatibility - Smart detection: numeric IDs for ID lookup, otherwise name lookup - Clean URL after opening to prevent duplicate on refresh - Show toast error when host not found --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501) Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal. This adds Ctrl+Alt+ as an alternative that sends Ctrl+. - Ctrl+Alt+W → Ctrl+W (nano search, delete word) - Ctrl+Alt+T → Ctrl+T (transpose chars) - Ctrl+Alt+N → Ctrl+N (next line) - Ctrl+Alt+Q → Ctrl+Q (XON flow control) Fixes Termix-SSH/Support#407 * feat: remove locales * New Crowdin updates (#504) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * feat: add option to disable update checker (#502) * feat: add option to disable update checker Add a new setting in User Profile > Settings to disable automatic update checking on startup and dashboard. - Adds 'Disable Update Check' toggle in profile settings - Skips GitHub API calls when disabled (reduces network requests) - Works for both web app and Electron client Fixes Termix-SSH/Support#410 * feat: remove locales --------- Co-authored-by: LukeGus * New Crowdin updates (#505) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * feat: add crowdin i18n * feat: remove locales * New Crowdin updates (#506) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * New translations en.json (Norwegian) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Swedish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Finnish) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Bulgarian) * New translations en.json (Indonesian) * New translations en.json (Hindi) * New translations en.json (Bengali) * New translations en.json (Thai) * feat: update readme * feat: update readme * feat: update credential editor to use submitting system and add health monitor * feat: added toggle for command pallete * feat: added close button on tab dropdown * feat: added sidebar management and improved some host manager UI/UX * feat: re-added missing users.ts route from merge * feat: add toggle for password reset feature in admin settings (#508) * feat: add sudo support for file manager operations (#509) * fix: add sudo support for listFiles and improve permission error handling (#512) * feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated * feat: add copy password button and fixed new line carriage issues and backend crash for auth key * feat: added quick connection system (ad-hoc) * Enter Key for Quick Login (#513) * feat: added -r and -l support for tunnels * feat: begin dashboard overhaul by splitting into cards and adding customization * feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc. * feat: add auth.tsx suppot for fullscreen * feat: greatly improve network graph ui/ux and migrated to use translations and theme system * feat: update to use blacksmith * feat: improve ui for customized tabs and hide add/edit host/credential when submiting * feat: add warpgate support with a dialog (terminal only) * feat: expand warpgate to docker/file manager * fix: docker not working wtih warpgate and none auth failing for terminal * fix: prevent owner permission loss when sharing host to own role (#514) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * fix: prevent owner permission loss when sharing host to own role Fixes #391 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: SSH key passphrase not passed for Docker and Tunnel (#521) * perf: optimize Host Manager for large host lists - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. * fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel Database field is `key_password` but code used `keyPassword`. Added fallback to check both field names. Affected: - docker.ts: Docker SSH connections with encrypted keys - tunnel.ts: Tunnel connections with encrypted keys --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * perf: optimize Host Manager for large host lists (#520) - Add ServerStatusContext for shared status polling (reduces API calls from N to 1) - Move TooltipProvider to component root (eliminates N context instances) - Add pagination with "Show More" button (limits initial DOM nodes per folder) Fixes performance issues when managing ~1000 hosts with status monitoring enabled. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing mimeTypes definition for image preview (#518) Fixes Termix-SSH/Support#408 * fix: prevent session reset when updating host properties (#517) Move WebSocket cleanup logic to a separate unmount-only effect to prevent SSH sessions from being closed when host properties are updated. Closes Termix-SSH/Support#401 * fix: backend type error * feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385) * feat: improved conneciton log ui/logic * feat: improved conneciton log ui/logic * feat: expanded connection log to work across all components (readying for release) * feat: update readme * feat: update readme * fix: build error * fix: build error * fix: changed ver * chore: clean up * chore: continue clean up * fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies * fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field * fix: update readme and run cleaner * fix: update readme * fix: update readme * fix: update readme * feat: update chinese readme * feat: add modern DH group KEX algorithms for better compatibility Add diffie-hellman-group15/16/17/18-sha512 key exchange algorithms which are supported by ssh2 library but were not configured in Termix. These algorithms provide: - Better compatibility with modern SSH servers (FreeBSD, OpenBSD, etc.) - Stronger security with larger DH groups - RFC 8268 compliance Related to Termix-SSH/Support#205 --------- Co-authored-by: Steven Josefs Co-authored-by: LukeGus Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> * feat: clarify that hostname/FQDN is supported in IP address field (#529) * Feature request network graph * Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker * Handle enter button (#481) * Update Crowdin configuration file * Update Crowdin configuration file * Update Linux Portable section with AUR link (#474) * fix: file manager incorrectly decoding/encoding when editing files (#476) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: build error on docker (#477) * fix: electron build errors and skip macos job * fix: testflight submit failure * fix: made submit job match build type * fix: resolve Vite build warnings for mixed static/dynamic imports (#473) * Update Crowdin configuration file * Update Crowdin configuration file * fix: resolve Vite build warnings for mixed static/dynamic imports - Convert all dynamic imports of main-axios.ts to static imports (10 files) - Convert all dynamic imports of sonner to static imports (4 files) - Add manual chunking configuration to vite.config.ts for better bundle splitting - react-vendor: React and React DOM - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge - monaco: Monaco Editor - codemirror: CodeMirror and related packages - Increase chunkSizeWarningLimit to 1000kB This resolves Vite warnings about mixed import strategies preventing proper code-splitting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent) * fix: build error on docker --------- Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude * Increase max old space size for npm builds * Increase Node.js memory limit in Dockerfile * Remove NODE_OPTIONS from build commands in Dockerfile * Change runner to blacksmith-4vcpu-ubuntu-2404 * fix: build error on docker * Add handle on enter button; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Gaylord Julien Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: LukeGus * fix: remove top tech * fix: update readme * fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 * fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 * fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 * feat: add Docker container healthcheck (#493) * fix: owner should not be marked as shared when host is shared to their role (#492) * fix: use correct MIME types for image preview (#491) * fix: prevent session reset when updating host properties (#490) * fix: add shell creation timeout and improve error handling (#489) * fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) * fix: delete all related data when removing user (#487) * fix: nginx permission denied on restricted kernels (#486) * fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 * feat: add firewall status widget for server stats (#484) * Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json * New Crowdin updates (#472) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Afrikaans) * New translations en.json (Arabic) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Dutch) * New translations en.json (Norwegian) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Serbian (Cyrillic)) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (English) * New translations en.json (Vietnamese) * New translations en.json (German) * feat: add listening ports widget for server stats (#483) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: fix network stats merge and add openapi jsdocs comments * feat: add workflow/config to auto generate openapi json * feat: remove locales * feat: support URL routes to … * fix: unable to delete OIDC user due to missing table cleanup (#576) deleteUserAndRelatedData was missing cleanup for networkTopology, dashboardPreferences, and opksshTokens tables. When deleting an OIDC user who had records in these tables, the final DELETE on users hit SQLITE_CONSTRAINT_FOREIGNKEY. Add explicit deletes for all three tables before the user row deletion. * fix: use lightweight wget for Docker healthcheck (#558) - Replace node -e healthcheck with wget (avoid spawning full Node.js process) - Increase start-period from 30s to 60s for slower machines * feat: add "Remember Me" option to login (#585) When checked, browser sessions get 30-day expiry (same as desktop) instead of the default 7-day. The flag is passed through the full login chain including TOTP verification flow. * feat: expand environment variables in file manager path bar (#583) Resolve $VAR and ~ paths via SSH echo before navigating. Add /ssh/resolvePath backend endpoint, resolveSSHPath API function, and intercept in loadDirectory. Fix confirmEditingPath not to prefix ~ or $ paths with /. * fix: add Ctrl+Shift+C keyboard shortcut for terminal copy (#581) Ctrl+Shift+C was not handled in the custom key event handler, so users had no keyboard shortcut to copy selected text from the terminal. Adds the shortcut alongside existing Cmd+C (macOS) support, copying the current selection to clipboard. * feat: add OIDC user allowlist for registration control (#580) Add allowed_users field to OIDC config supporting comma-separated patterns: exact emails (user@example.com), domain suffixes (@example.com), or empty for unrestricted access. Only new user registrations are checked; existing users and first-user setup are unaffected. * fix: OIDC redirect_uri incorrectly resolves to localhost (#577) The OIDC authorize endpoint used the client Origin header to build the redirect_uri. Desktop/mobile apps connecting to a remote server send their local origin (e.g. http://localhost:5173), which got force-rewritten to http://localhost:30001. This broke OIDC for all non-browser clients. Use req.protocol + req.get("Host") instead, which correctly resolves to the server's own address. trust proxy is already enabled so this works behind reverse proxies too. * fix: keep terminal tab open on connection failure (#574) When SSH connection fails before being established, the terminal tab auto-closes immediately, leaving no error message visible to the user. Added wasConnectedRef to track whether the connection was ever established. Now only auto-closes tab on disconnect if it was previously connected. If never connected, shows error message instead. * fix: prevent restart loop when PUID/PGID is set to 0 (#571) When PUID=0, usermod sets the node user's UID to 0 (root), then gosu re-executes the script as node — but since node is now UID 0, the id check passes again, causing an infinite loop. Skip the gosu step when PUID=0 since the process is already root. * feat: add README translations for 12 additional languages (#569) Add translated README files for: Japanese, Korean, French, German, Spanish, Portuguese (BR), Russian, Arabic, Hindi, Turkish, Vietnamese, and Italian. Update language navigation bar in all README files including existing English and Chinese versions. * fix: allow OIDC users to export database without password (#575) OIDC users have no password, but the export endpoint unconditionally required password authentication. This reuses the same OIDC branch pattern already implemented in the import endpoint: skip password validation for OIDC users and authenticate via authenticateOIDCUser instead. Frontend now also skips the password input for OIDC users. * fix: SSH session leak — SFTP channel reuse + shared connection pool (#556) - Cache SFTP channel per session in file-manager, reuse across all operations - Remove all sftp.end() calls, channel lifecycle follows session - Auto-rebuild SFTP channel on error/close events - Extract SSHConnectionPool from server-stats into shared module - Factory-based pool API: getConnection(key, factory) / withConnection() - Migrate server-stats and tunnel killRemoteTunnelByMarker to shared pool - Refactor tunnel kill from callback hell to async/await Fixes: Termix-SSH/Support#485 * fix: handle DEC private mode sequences in syntax highlighter (#562) Extend CSI parameter byte matching to include ?, >, =, ! per ECMA-48. Previously sequences like \x1b[?1h and \x1b[?25l were not recognized as ANSI segments and fell through to plain text processing. * fix: catch disconnected client error in listFiles fallback (#564) When SFTP times out and the fallback calls client.exec() on an already-disconnected SSH client, the synchronous throw was not caught, crashing the backend process. * fix: forward Shift+Tab as backtab escape sequence (#563) Explicitly handle Shift+Tab in the custom key event handler by sending \x1b[Z (CSI Z) directly, with preventDefault to stop browser/WebView focus navigation from consuming the keystroke. * fix: disable font ligatures in terminal by default (#561) Nerd Fonts ligatures cause != to render as ≠, <= as ≤, etc. This is confusing for terminal use and breaks cursor positioning. * fix: bypass ls alias in file manager fallback (#560) - Add --color=never to command ls in SFTP fallback path - Use /bin/ls absolute path in sudo fallback to avoid alias/wrapper * feat: add overwrite option for JSON host import (#559) - Add overwrite mode to bulk-import endpoint, matching by ip:port:username - Import button now shows dropdown with "skip existing" and "overwrite existing" options - Response includes created/updated/skipped/failed counts * fix: status check tcpPing causing kex_exchange_identification flood in sshd logs (#557) - Complete SSH identification exchange before closing socket in tcpPing - Read server banner, send client identification string, then close gracefully - Prevents sshd from logging kex_exchange_identification errors on every ping - Increase default statusCheckInterval from 30s to 60s * fix: add WebSocket protocol-level ping to prevent proxy timeouts (#572) The existing application-level ping (JSON message via setInterval) stops working when the browser tab is backgrounded due to timer throttling. Reverse proxies like Cloudflare (100s) and Nginx Proxy Manager (60s) then drop the idle WebSocket connection. Add server-side ws.ping() every 30s for both terminal and docker console WebSocket servers. Protocol-level pings are handled by the browser's WebSocket implementation, unaffected by tab throttling. * feat: embed backend server in Electron desktop app (#539) * feat: embed backend server in Electron desktop app - Fork backend process on app startup (production mode only) - Store data in userData/server-data for desktop users - Unpack dist/ and native modules (better-sqlite3, ssh2) from asar - Resolve asar vs asar.unpacked paths for forked process - Serve frontend static files from Express for iframe auth flow - Add SPA fallback for non-API routes - Graceful shutdown with SIGTERM + 5s force kill - Add get-embedded-server-status IPC handler * fix: improve electron embedded server reliability - Extract duplicate fetch polyfill to shared httpFetch function - Wait for backend ready before creating window (15s timeout) - Use IPC message for graceful shutdown (SIGTERM doesn't work on Windows) - Simplify SPA fallback to use Accept header instead of route blacklist - Add IPC shutdown handler in backend starter * feat: system tray minimize and runtime server switching * fix: resolve all 274 ESLint errors in backend source files (#591) - Remove unused imports and variables (110 no-unused-vars) - Replace explicit any with proper types (80 no-explicit-any) - Add comments to intentionally empty catch blocks (66 no-empty) - Fix no-extra-boolean-cast, prefer-const, no-async-promise-executor - No logic changes, lint-only cleanup * fix: add missing clipboard shortcuts for terminal and docker console (#589) Terminal and Docker Console were missing Ctrl+Shift+C (copy selection), Ctrl+Insert (copy), and Shift+Insert (paste) shortcuts. Docker Console also lacked Ctrl+V paste entirely. Added consistent clipboard key handlers to both components. * fix: resolve all ESLint errors and increase CI heap size (#588) * fix: resolve all ESLint errors blocking CI - Downgrade mass legacy violations to warnings (no-unused-vars, no-explicit-any, no-empty, no-unused-expressions) - Turn off no-control-regex (terminal app uses control chars) - Ignore dist/release/Mobile dirs from linting - Auto-fix prefer-const, no-extra-boolean-cast - Fix real bugs: async promise executor, rules-of-hooks, dupe-else-if, constant-binary-expression, case-declarations - Run prettier --write on all files * fix: increase Node.js heap size for CI build step vite build OOMs on 2vCPU runner with default heap limit. Set NODE_OPTIONS=--max-old-space-size=4096 to prevent it. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: reduce excessive disk writes from unconditional database persistence (#590) The in-memory database was serialized, encrypted, and written to disk every 15 seconds regardless of whether any data had changed. Combined with the 2-second debounced save trigger on every modification, this caused ~1GB/hour of disk writes even when idle. Add a dirty flag so the periodic save only writes when data has actually changed, and increase the safety-net interval from 15 seconds to 5 minutes. Triggered saves (on actual data modifications) continue to fire after the existing 2-second debounce. Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: opkssh 404 and browser issues * fix: resolve backend TypeScript strict compilation errors (#592) Fix 56 type errors under tsconfig.node.json caused by overly broad unknown/Record types from the ESLint cleanup. - Add JumpHostConfig interface for jump host chain resolution - Use proper types for resolvedCredentials, ConnectConfig, ProxyNode - Fix setWindow call to pass all 4 required arguments - Remove duplicate responseTimeout declarations in same scope - Add missing ClientChannel import * fix: clear Electron partition cookies on auth failure and logout (#599) When the Docker container is recreated, a new JWT secret is generated and all existing tokens become invalid. The 401 interceptor clears localStorage and document.cookie, but the Electron persistent partition cookies (partition: "persist:termix") are managed by Chromium and not accessible via document.cookie. This leaves the stale token in place, causing an infinite 401 retry loop and UI flickering. Add a clear-session-cookies IPC handler in the main process that uses session.cookies API to remove all partition cookies. Invoke it from the 401 interceptor and logoutUser function in the renderer. * refactor: unify Drizzle schema property names to camelCase (#598) * feat: add bulk host settings editing with multi-select UI Add PATCH /ssh/bulk-update endpoint for partial updates on multiple hosts. Frontend adds selection mode with checkboxes, folder-level select-all, and a floating action bar for monitoring, features, folder, and pin operations. * fix: correct Drizzle schema field name access for SSH credentials The sshCredentials schema uses mixed naming: private_key and key_password (snake_case) but authType and keyType (camelCase). Multiple files accessed these fields with wrong names, causing undefined values at runtime. Fixed direct bugs: - docker.ts: host.keyPassword -> host.key_password - docker-console.ts: jumpHost.keyPassword -> jumpHost.key_password - auth-manager.ts: cred.privateKey -> cred.private_key - auth-manager.ts: cred.passphrase -> cred.key_password Cleaned up redundant fallback patterns (e.g. credential.auth_type || credential.authType) across 8 files to use the correct property name directly. * refactor: unify Drizzle schema property names to camelCase Rename 18 snake_case Drizzle schema properties to camelCase across users, sshData, and sshCredentials tables. SQL column names unchanged, no database migration needed. This fixes the root cause of field name mismatch bugs where code accessed Drizzle results using camelCase but schema used snake_case. Updated all references across 21 files: routes, SSH modules, crypto utilities, type definitions, and field encryption mappings. * feat: add bulk host settings editing with multi-select UI (#596) Add PATCH /ssh/bulk-update endpoint for partial updates on multiple hosts. Frontend adds selection mode with checkboxes, folder-level select-all, and a floating action bar for monitoring, features, folder, and pin operations. * feat: add global default for status check and metrics intervals (#595) Add global monitoring interval settings that apply to all hosts by default, with per-host override capability. Hosts use the global default unless explicitly configured with a custom interval. * fix: shared credentials fail for OIDC users due to missing re-encryption (#593) When an admin shares a host with an OIDC user who hasn't logged in yet, a pending shared credential is created with needsReEncryption=true. The OIDC callback login path was missing the call to reEncryptPendingCredentialsForUser(), so the credential stayed pending forever. Additionally, server-stats.ts accessed the shared credential without a null check, causing a crash. - Add reEncryptPendingCredentialsForUser() call to OIDC callback login - Add null guard for getSharedCredentialForUser() in server-stats.ts * feat: SSH session persistence across browser refresh (#594) * feat: decouple SSH session lifecycle from WebSocket connections SSH sessions now persist independently of browser tab state. When a WebSocket disconnects (tab close/refresh), the SSH connection is detached rather than destroyed, allowing reattachment within a configurable idle timeout (default 30 min). - Add TerminalSessionManager singleton with output buffering, idle timeout, health checks, and per-user session limits - Refactor terminal.ts to route SSH state through session manager - Frontend tracks session IDs in localStorage for reconnection - Tabs persist to localStorage and restore on page reload - Add GET/POST /terminal/session_settings API endpoints - Clear session storage on logout * fix: resolve session persistence bugs found during code review - Fix detachWs timeout leak on double-detach (clear existing timeout first) - Fix healthCheck mutating Map during iteration (collect IDs first) - Capture sessionId at stream bind time to prevent cross-session data leakage - Add session destruction in OPKSSH/auth error paths that previously leaked - Add session destruction in shell init early-return paths - Reset isConnecting flag in attachSession handler - Use current terminal dimensions in sessionExpired handler (not stale closure) - Fix nextTabId race condition with synchronous initialization from restored tabs - Validate restored currentTab against actual restored tabs - Add clearTermixSessionStorage to logout error path * fix: correct raw SQL field name mismatches in crypto and credential routes (#601) Fix camelCase/snake_case mismatches left after #598 schema refactor: - credentials.ts: 5 remaining snake_case fields in Drizzle insert/update - data-crypto.ts: migrateUserSensitiveFields() accessed raw SQL records with camelCase keys (keyPassword, privateKey, etc.) but SELECT * returns snake_case columns, causing undefined values and silent data loss - lazy-field-encryption.ts: getSensitiveFieldsForTable() missing 4 ssh_data fields (sudoPassword, autostartPassword, autostartKey, autostartKeyPassword), migrateRecordSensitiveFields() now uses propertyToColumn() to resolve snake_case keys from raw SQL results * feat: add per-host SSH keepalive configuration (#603) * fix: correct raw SQL field name mismatches in crypto and credential routes Fix camelCase/snake_case mismatches left after #598 schema refactor: - credentials.ts: 5 remaining snake_case fields in Drizzle insert/update - data-crypto.ts: migrateUserSensitiveFields() accessed raw SQL records with camelCase keys (keyPassword, privateKey, etc.) but SELECT * returns snake_case columns, causing undefined values and silent data loss - lazy-field-encryption.ts: getSensitiveFieldsForTable() missing 4 ssh_data fields (sudoPassword, autostartPassword, autostartKey, autostartKeyPassword), migrateRecordSensitiveFields() now uses propertyToColumn() to resolve snake_case keys from raw SQL results * feat: add per-host SSH keepalive configuration MikroTik and similar devices ignore SSH-level keepalive requests, causing ssh2 to disconnect after keepaliveCountMax unanswered pings (default: 30s × 4 = 120s). Add keepaliveInterval and keepaliveCountMax to terminalConfig, allowing per-host override. Users can set keepaliveInterval to 0 to disable SSH keepalives and rely on TCP keepalive instead. Backend reads the values from hostConfig.terminalConfig sent via WebSocket. Frontend exposes the settings in the Advanced section of the terminal configuration tab. * fix: enable Electron desktop app to run standalone with embedded backend (#609) * fix: enable Electron desktop app to run standalone with embedded backend The Electron app already starts an embedded backend server via fork(), but the frontend had no awareness of it. On first launch, users were always prompted to enter a remote server URL even though a local backend was already running on localhost. - Add getEmbeddedServerStatus() to query the embedded backend via IPC - Add embeddedMode flag in main-axios.ts, detected at initialization - In embedded mode without a configured remote URL, getApiUrl() now routes each service to its own localhost port (30001, 30003, etc.) matching the dev-mode multi-port strategy - Auth.tsx checkServerConfig() detects embedded backend and skips the server configuration form, falling through to the standard login UI * fix: add explicit "Use Local Server" button and improve embedded detection The silent auto-detection of the embedded backend can fail due to IPC timing issues or backend startup failures. Add a visible "Use Local Server" button on the Server Configuration page that: - Probes http://localhost:30001/health to verify the backend is running - Sets embeddedMode and reinitializes API instances with per-port routing - Falls through to the standard login form (no iframe needed) Also adds setEmbeddedMode() export so the UI can explicitly activate embedded mode, and adds i18n keys for the new UI elements. * fix: disable asar for embedded backend, improve tray and health probe The forked backend process uses ESM imports and cannot resolve modules from inside an asar archive (NODE_PATH is CJS-only). Disabling asar ensures node_modules are plain files accessible to the backend. - Disable asar packaging so forked backend can resolve ESM imports - Remove node_modules exclusion from files config - Fix system tray: use nativeImage with template on macOS, add error handling, only minimize-to-tray when tray exists - Add retry mechanism for backend health probe (10 retries over ~30s) to handle slow backend startup - Always show "Use Local Server" button in Electron mode regardless of backend running status - Add file logging to userData for debugging GUI-launched app * feat: unified proxy + jump host pipeline with HTTP CONNECT support (#608) * fix: unify proxy and jump host connection paths SOCKS5 proxy and jump host logic were mutually exclusive — the SOCKS5 branch returned early, so jump hosts were never reached when both were configured. This affected terminal, file-manager, docker, and server-stats modules. Refactored all four modules to a unified pipeline: - createJumpHostChain() accepts optional socks5Config parameter - When both proxy and jump hosts are configured, the proxy socket is created to the first jump host internally, then used as transport - Three-branch flow: jumpHosts (with optional proxy) → proxy-only → direct - Enhanced error logging with hopIndex, totalHops, previousHop fields * feat: add HTTP CONNECT proxy support with mixed chain Extend ProxyNode.type to support 'http' alongside SOCKS4/5. New proxy-helper.ts provides: - createHttpConnectConnection(): HTTP CONNECT tunnel with Basic auth and optional existingSocket for chaining - createMixedProxyChainConnection(): routes each hop to SOCKS or HTTP CONNECT based on node type; pure-SOCKS chains still use the optimized SocksClient.createConnectionChain path - createProxyConnection(): unified entry point (backward-compatible alias for createSocks5Connection) - testProxyConnectivity(): connect through proxy to test target, measure latency socks5-helper.ts becomes a re-export shim — all existing import sites continue to work unchanged. * feat: add proxy connectivity test endpoint New POST /ssh/db/proxy/test API accepts singleProxy, proxyChain, and optional testTarget. Calls testProxyConnectivity() and returns success/latencyMs or error. Frontend testProxyConnection() function added to main-axios.ts. * feat: add connection path visualization and HTTP CONNECT UI - Proxy chain type selector now includes HTTP CONNECT option - Test Connection button calls /ssh/db/proxy/test with loading state - Connection path visualization shows the full route when proxy and/or jump hosts are configured: [You] → [Proxy] → [Jump Host] → [Target] - New i18n keys: httpConnect, testProxy, testingProxy, proxyTestSuccess, proxyTestFailed, connectionPath * fix: add Cache-Control no-store header to all API responses (#607) Backend API responses had no Cache-Control headers, making them vulnerable to caching by intermediate reverse proxies. This could cause stale data in the UI when Termix is deployed behind certain proxy configurations (e.g., SWAG, Pangolin). Add Cache-Control: no-store middleware to all 6 Express apps to prevent any proxy or browser from caching API responses. * fix: preserve external reverse proxy X-Forwarded headers in internal Nginx (#606) The internal Nginx proxy was overwriting X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port with local values ($scheme, $http_host, $server_port), discarding headers set by the external reverse proxy. This caused getRequestOrigin() to always return http:// with the internal port for OpkSSH authentication URLs. Add map directives to preserve original X-Forwarded-* headers from the external proxy, falling back to local values when no external proxy is present. Also remove duplicate header directives in the WebSocket location. * fix: host update fails silently due to statsConfig double-serialization (#605) - Fix statsConfig double JSON.stringify in both frontend (createSSHHost, updateSSHHost) and backend (POST/PUT handlers). Frontend was pre- stringifying statsConfig before sending, then backend stringified again, corrupting the data after multiple edits and eventually causing Zod validation failures on the edit form. - Add missing sudoPassword field to createSSHHost and updateSSHHost submit data, which was being set in onSubmit but dropped during API call construction. - Add toast notification in handleFormError so users get visible feedback when form validation fails instead of silent no-op. * fix: Ctrl+C copies selection and clipboard error feedback (#604) * fix: correct raw SQL field name mismatches in crypto and credential routes Fix camelCase/snake_case mismatches left after #598 schema refactor: - credentials.ts: 5 remaining snake_case fields in Drizzle insert/update - data-crypto.ts: migrateUserSensitiveFields() accessed raw SQL records with camelCase keys (keyPassword, privateKey, etc.) but SELECT * returns snake_case columns, causing undefined values and silent data loss - lazy-field-encryption.ts: getSensitiveFieldsForTable() missing 4 ssh_data fields (sudoPassword, autostartPassword, autostartKey, autostartKeyPassword), migrateRecordSensitiveFields() now uses propertyToColumn() to resolve snake_case keys from raw SQL results * fix: Ctrl+C copies selection instead of sending SIGINT when text is selected When terminal has an active text selection, Ctrl+C now copies the selected text to clipboard and clears the selection. When nothing is selected, Ctrl+C sends SIGINT as before. Ctrl+Shift+C continues to work as a dedicated copy shortcut. Also adds toast notifications when clipboard operations fail instead of silently swallowing errors. Applied to both the main terminal and Docker console terminal. * fix: improve IPv6 connection handling with bracket stripping and ENETUNREACH hint (#602) Strip square brackets from IPv6 addresses at all SSH connection entry points (terminal, docker, docker-console, file-manager, server-stats, tunnel) to handle addresses entered as [::1] format. Add ENETUNREACH error hint in terminal.ts that detects IPv6 addresses and suggests checking Docker IPv6 network configuration. Also fix remaining snake_case field names in credentials.ts (same as #601). * fix: backend catch error * feat: improve selection UI * fix: remove tab naming * fix: squished ssh toolbar split ui buttons * feat: improve persistent tabs, data not saving for users, various ui inconsistencies * fix: tab context creating random context errors * fix: improve terminal session logic (not perfect)s * fix: terminal session logic creating errors and electron app not having UI to do local mode * fix: desktop build error * feat: update readme * feat: update readme * feat: update readme * Update README.md (#612) * fix: macos build failure and updated rest of the langs for readme * feat: update readmes to fix spelling error * feat: update readmes to fix spelling error * fix: oidc failures, opkssh not wokring, added some qol to selecting * fix: buyild erorr * fix: buyild erorr * fix: macos build error * fix: opkssh, macos copy/paste, admin settings global default backend crashes, oidc redirect issues, updated translations * chore: clean up files * fix: oidc http/https redirect issue * fix: build error * feat: update json import with all new fields --------- Co-authored-by: skyam25 Co-authored-by: ZacharyZcR Co-authored-by: Steven Josefs Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com> Co-authored-by: Termix CI Co-authored-by: Claude Co-authored-by: Nunzio Marfè Co-authored-by: Gaylord Julien Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com> Co-authored-by: Deepansh Khurana Co-authored-by: Dylan Ysmal Co-authored-by: TomyJan Co-authored-by: ywaf <52742690+ywaf@users.noreply.github.com> --- .dockerignore | 1 - .github/workflows/electron.yml | 2 - .github/workflows/pr-check.yml | 3 + README-CN.md | 172 -- README.md | 27 +- docker/Dockerfile | 9 +- docker/entrypoint.sh | 25 +- docker/nginx-https.conf | 47 +- docker/nginx.conf | 46 +- electron-builder.json | 17 +- electron/main.cjs | 465 +++-- electron/preload.js | 8 + eslint.config.js | 10 +- package-lock.json | 112 +- package.json | 18 +- public/sw.js | 7 + readme/README-AR.md | 187 ++ readme/README-CN.md | 189 ++ readme/README-DE.md | 187 ++ readme/README-ES.md | 187 ++ readme/README-FR.md | 186 ++ readme/README-HI.md | 187 ++ readme/README-IT.md | 187 ++ readme/README-JA.md | 186 ++ readme/README-KO.md | 186 ++ readme/README-PT.md | 187 ++ readme/README-RU.md | 187 ++ readme/README-TR.md | 187 ++ readme/README-VI.md | 187 ++ repo-images/YouTube.jpg | Bin 0 -> 427567 bytes repo-images/YouTube.png | Bin 152817 -> 0 bytes src/backend/dashboard.ts | 11 +- src/backend/database/database.ts | 155 +- src/backend/database/db/index.ts | 77 +- src/backend/database/db/schema.ts | 53 +- src/backend/database/routes/credentials.ts | 73 +- src/backend/database/routes/rbac.ts | 23 +- src/backend/database/routes/snippets.ts | 10 +- src/backend/database/routes/ssh.ts | 557 +++++- src/backend/database/routes/terminal.ts | 114 ++ src/backend/database/routes/users.ts | 581 +++--- src/backend/scripts/enable-ssl.sh | 3 +- src/backend/ssh/auth-manager.ts | 14 +- src/backend/ssh/docker-console.ts | 92 +- src/backend/ssh/docker.ts | 439 +++-- src/backend/ssh/file-manager.ts | 1619 ++++++++++------- src/backend/ssh/host-key-verifier.ts | 1 - src/backend/ssh/opkssh-auth.ts | 22 +- src/backend/ssh/server-stats.ts | 970 +++++----- src/backend/ssh/ssh-connection-pool.ts | 225 +++ src/backend/ssh/terminal-session-manager.ts | 489 +++++ src/backend/ssh/terminal.ts | 768 +++++--- src/backend/ssh/tunnel.ts | 439 +++-- src/backend/ssh/widgets/common-utils.ts | 8 +- src/backend/ssh/widgets/cpu-collector.ts | 2 +- src/backend/ssh/widgets/disk-collector.ts | 2 +- .../ssh/widgets/login-stats-collector.ts | 13 +- src/backend/ssh/widgets/memory-collector.ts | 2 +- src/backend/ssh/widgets/network-collector.ts | 5 +- .../ssh/widgets/processes-collector.ts | 5 +- src/backend/ssh/widgets/system-collector.ts | 5 +- src/backend/ssh/widgets/uptime-collector.ts | 5 +- src/backend/starter.ts | 19 +- src/backend/utils/auth-manager.ts | 191 +- src/backend/utils/auto-ssl-setup.ts | 4 +- .../credential-system-encryption-migration.ts | 4 +- src/backend/utils/data-crypto.ts | 71 +- src/backend/utils/database-file-encryption.ts | 8 +- src/backend/utils/database-save-trigger.ts | 12 + src/backend/utils/field-crypto.ts | 18 +- src/backend/utils/lazy-field-encryption.ts | 78 +- src/backend/utils/permission-manager.ts | 10 +- src/backend/utils/proxy-agent.ts | 4 +- src/backend/utils/proxy-helper.ts | 340 ++++ .../utils/shared-credential-manager.ts | 12 +- src/backend/utils/simple-db-ops.ts | 11 +- src/backend/utils/socks5-helper.ts | 125 +- src/backend/utils/ssh-key-utils.ts | 17 +- src/backend/utils/system-crypto.ts | 30 +- src/backend/utils/user-agent-parser.ts | 20 + src/backend/utils/user-crypto.ts | 15 +- src/backend/utils/user-data-import.ts | 9 +- src/constants/terminal-themes.ts | 2 + src/lib/clipboard-provider.ts | 47 + src/lib/terminal-syntax-highlighter.ts | 6 +- src/locales/en.json | 92 +- src/locales/translated/af_ZA.json | 47 +- src/locales/translated/ar_SA.json | 47 +- src/locales/translated/bg_BG.json | 47 +- src/locales/translated/bn_BD.json | 47 +- src/locales/translated/ca_ES.json | 47 +- src/locales/translated/cs_CZ.json | 47 +- src/locales/translated/da_DK.json | 47 +- src/locales/translated/de_DE.json | 47 +- src/locales/translated/el_GR.json | 47 +- src/locales/translated/es_ES.json | 47 +- src/locales/translated/fi_FI.json | 47 +- src/locales/translated/fr_FR.json | 47 +- src/locales/translated/he_IL.json | 47 +- src/locales/translated/hi_IN.json | 47 +- src/locales/translated/hu_HU.json | 47 +- src/locales/translated/id_ID.json | 47 +- src/locales/translated/it_IT.json | 47 +- src/locales/translated/ja_JP.json | 47 +- src/locales/translated/ko_KR.json | 47 +- src/locales/translated/nl_NL.json | 47 +- src/locales/translated/no_NO.json | 47 +- src/locales/translated/pl_PL.json | 47 +- src/locales/translated/pt_BR.json | 47 +- src/locales/translated/pt_PT.json | 47 +- src/locales/translated/ro_RO.json | 47 +- src/locales/translated/ru_RU.json | 47 +- src/locales/translated/sr_SP.json | 47 +- src/locales/translated/sv_SE.json | 47 +- src/locales/translated/th_TH.json | 47 +- src/locales/translated/tr_TR.json | 47 +- src/locales/translated/uk_UA.json | 47 +- src/locales/translated/vi_VN.json | 47 +- src/locales/translated/zh_CN.json | 47 +- src/locales/translated/zh_TW.json | 47 +- src/types/electron.d.ts | 4 + src/types/index.ts | 11 +- src/types/stats-widgets.ts | 4 + src/ui/desktop/DesktopApp.tsx | 63 +- src/ui/desktop/apps/admin/AdminSettings.tsx | 1 + .../apps/admin/tabs/DatabaseSecurityTab.tsx | 33 +- .../apps/admin/tabs/GeneralSettingsTab.tsx | 230 ++- .../apps/admin/tabs/OIDCSettingsTab.tsx | 20 + .../apps/features/docker/DockerManager.tsx | 2 +- .../docker/components/ConsoleTerminal.tsx | 93 +- .../features/file-manager/FileManager.tsx | 117 +- .../features/file-manager/FileManagerGrid.tsx | 62 +- .../file-manager/components/FileViewer.tsx | 28 +- .../features/server-stats/ServerStats.tsx | 12 +- .../apps/features/terminal/Terminal.tsx | 328 +++- .../host-manager/hosts/HostManagerEditor.tsx | 22 +- .../host-manager/hosts/HostManagerViewer.tsx | 595 +++++- .../hosts/tabs/HostGeneralTab.tsx | 172 +- .../hosts/tabs/HostStatisticsTab.tsx | 284 +-- .../hosts/tabs/HostTerminalTab.tsx | 69 +- src/ui/desktop/apps/tools/SSHToolsSidebar.tsx | 36 +- src/ui/desktop/authentication/Auth.tsx | 50 +- .../authentication/ElectronLoginForm.tsx | 14 +- .../authentication/ElectronServerConfig.tsx | 128 +- src/ui/desktop/navigation/AppView.tsx | 392 ++-- src/ui/desktop/navigation/LeftSidebar.tsx | 3 +- src/ui/desktop/navigation/tabs/TabContext.tsx | 131 +- src/ui/desktop/user/UserProfile.tsx | 32 + src/ui/main-axios.ts | 195 +- src/ui/mobile/MobileApp.tsx | 49 +- src/ui/mobile/apps/terminal/Terminal.tsx | 11 +- src/ui/mobile/authentication/Auth.tsx | 2 + src/ui/mobile/navigation/tabs/TabContext.tsx | 2 +- 153 files changed, 10978 insertions(+), 5141 deletions(-) delete mode 100644 README-CN.md create mode 100644 readme/README-AR.md create mode 100644 readme/README-CN.md create mode 100644 readme/README-DE.md create mode 100644 readme/README-ES.md create mode 100644 readme/README-FR.md create mode 100644 readme/README-HI.md create mode 100644 readme/README-IT.md create mode 100644 readme/README-JA.md create mode 100644 readme/README-KO.md create mode 100644 readme/README-PT.md create mode 100644 readme/README-RU.md create mode 100644 readme/README-TR.md create mode 100644 readme/README-VI.md create mode 100644 repo-images/YouTube.jpg delete mode 100644 repo-images/YouTube.png create mode 100644 src/backend/ssh/ssh-connection-pool.ts create mode 100644 src/backend/ssh/terminal-session-manager.ts create mode 100644 src/backend/utils/proxy-helper.ts create mode 100644 src/lib/clipboard-provider.ts diff --git a/.dockerignore b/.dockerignore index 46be46b6..ac38bfb2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,7 +31,6 @@ Thumbs.db .gitignore README.md -README-CN.md CONTRIBUTING.md LICENSE diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 56ed3acb..58d63847 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -319,7 +319,6 @@ jobs: cd flatpak-build flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml - # Determine the architecture ARCH=$(uname -m) if [ "$ARCH" = "x86_64" ]; then FLATPAK_ARCH="x86_64" @@ -329,7 +328,6 @@ jobs: FLATPAK_ARCH="$ARCH" fi - # Build bundle for the current architecture flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo - name: Create flatpakref file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 664c9538..9e77e4a2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -8,6 +8,9 @@ jobs: lint-and-build: runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + NODE_OPTIONS: "--max-old-space-size=4096" + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/README-CN.md b/README-CN.md deleted file mode 100644 index 5c224730..00000000 --- a/README-CN.md +++ /dev/null @@ -1,172 +0,0 @@ -# 仓库统计 - -

- English 英文 | - 中文 中文 -

- -![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 2025年9月1日获得 -

- -
-

- - Termix Banner -

- -如果你愿意,可以在这里支持这个项目!\ -[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) - -# 概览 - -

- - Termix Banner -

- -Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix -提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。 - -# 功能 - -- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件 -- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能 -- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件 -- **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们。 -- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥 -- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息 -- **仪表板** - 在仪表板上一目了然地查看服务器信息 -- **RBAC** - 创建角色并在用户/角色之间共享主机 -- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。 -- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。 -- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 -- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 -- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。 -- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理) -- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。 -- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。 -- **命令历史** - 自动完成并查看以前运行的 SSH 命令 -- **快速连接** - 无需保存连接数据即可连接到服务器 -- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接 -- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOCKS5、密码自动填充等。 -- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示 - -# 计划功能 - -查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。 - -# 安装 - -支持的设备: - -- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持) -- Windows(x64/ia32) - - 便携版 - - MSI 安装程序 - - Chocolatey 软件包管理器 -- Linux(x64/ia32) - - 便携版 - - AUR - - AppImage - - Deb - - Flatpak -- macOS(x64/ia32 on v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS(v15.1+) - - Apple App Store - - ISO -- Android(v7.0+) - - Google Play 商店 - - APK - -访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件: - -```yaml -services: - termix: - image: ghcr.io/lukegus/termix:latest - container_name: termix - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - termix-data:/app/data - environment: - PORT: "8080" - -volumes: - termix-data: - driver: local -``` - -# 赞助商 - -

- - DigitalOcean - -          - - Crowdin - -          - - Crowdin - -

- -# 支持 - -如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。 -请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持 -频道,但响应时间可能较长。 - -# 展示 - -[![](https://utfs.io/f/nGnSqDveMsqxqiKJF5EnObSopHatulx93N0E1KVsP7hvjMfF)](https://www.youtube.com/watch?v=j1_I1mkhUkE) - -

- Termix Demo 1 - Termix Demo 2 -

- -

- Termix Demo 3 - Termix Demo 4 -

- -

- Termix Demo 5 - Termix Demo 6 -

- -

- Termix Demo 7 - Termix Demo 8 -

- -

- Termix Demo 9 - Termix Demo 10 -

- -

- Termix Demo 11 - Termix Demo 12 -

- -某些视频和图像可能已过时或可能无法完美展示功能。 - -# 许可证 - -根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。 diff --git a/README.md b/README.md index 090a62af..192ded26 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,20 @@ # Repo Stats

- English English | - 中文 中文 + English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) @@ -39,7 +51,7 @@ free and self-hosted alternative to Termius available for all platforms. # Features -- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components +- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components. - **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections - **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. - **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. @@ -47,19 +59,20 @@ free and self-hosted alternative to Termius available for all platforms. - **Server Stats** - View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers - **Dashboard** - View server information at a glance on your dashboard - **RBAC** - Create roles and share hosts across users/roles -- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. +- **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. - **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI. Use URL routes to open any connection in full-screen. - **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)) -- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), PWA, and dedicated mobile/tablet app for iOS and Android. +- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android. - **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. - **Command History** - Auto-complete and view previously ran SSH commands - **Quick Connect** - Connect to a server without having to save the connection data - **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard - **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), etc. - **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile # Planned Features @@ -86,7 +99,7 @@ Supported Devices: - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - - ISO + - IPA - Android (v7.0+) - Google Play Store - APK @@ -140,7 +153,7 @@ channel, however, response times may be longer. # Screenshots -[![YouTube](./repo-images/YouTube.png)](https://youtu.be/8OYCPwS7ciA) +[![YouTube](./repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY)

Termix Demo 1 diff --git a/docker/Dockerfile b/docker/Dockerfile index a77f62dc..c8c701aa 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,8 +6,7 @@ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt COPY package*.json ./ -RUN rm -rf node_modules package-lock.json && \ - npm install --ignore-scripts --force && \ +RUN npm ci --ignore-scripts --force && \ npm cache clean --force # Stage 2: Build frontend @@ -51,7 +50,7 @@ ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production -RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu && \ +RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \ @@ -75,8 +74,8 @@ VOLUME ["/app/data"] EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD node -e "require('http').get('http://localhost:30001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget -q -O /dev/null http://localhost:30001/health || exit 1 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2764db5d..d0c8aeb3 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,16 +5,21 @@ PUID=${PUID:-1000} PGID=${PGID:-1000} if [ "$(id -u)" = "0" ]; then - echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..." + if [ "$PUID" = "0" ]; then + echo "Running as root (PUID=0, PGID=$PGID)" + chown -R root:root /app/data /app/uploads /app/nginx 2>/dev/null || true + else + echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..." - groupmod -o -g "$PGID" node 2>/dev/null || true - usermod -o -u "$PUID" node 2>/dev/null || true + groupmod -o -g "$PGID" node 2>/dev/null || true + usermod -o -u "$PUID" node 2>/dev/null || true - chown -R node:node /app/data /app/uploads /app/nginx 2>/dev/null || true + chown -R node:node /app/data /app/uploads /app/nginx 2>/dev/null || true - echo "User node is now UID: $PUID, GID: $PGID" + echo "User node is now UID: $PUID, GID: $PGID" - exec gosu node:node "$0" "$@" + exec gosu node:node "$0" "$@" + fi fi export PORT=${PORT:-8080} @@ -52,8 +57,12 @@ else ls -ld /app/data/.opk fi -if [ ! -d "/app/opkssh" ]; then - echo "WARNING: OPKSSH binary directory not found at /app/opkssh" +OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh" +if [ ! -d "$OPKSSH_DIR" ]; then + echo "WARNING: OPKSSH binary directory not found at $OPKSSH_DIR" + echo "OPKSSH will be downloaded automatically on first use." +else + echo "OPKSSH binary directory found at $OPKSSH_DIR" fi if [ "$ENABLE_SSL" = "true" ]; then diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 13f76c65..c0cd6081 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -26,6 +26,21 @@ http { set_real_ip_from 127.0.0.1; real_ip_header X-Forwarded-For; + map $http_x_forwarded_proto $proxy_x_forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; + } + + map $http_x_forwarded_host $proxy_x_forwarded_host { + default $http_x_forwarded_host; + '' $http_host; + } + + map $http_x_forwarded_port $proxy_x_forwarded_port { + default $http_x_forwarded_port; + '' $server_port; + } + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; @@ -75,7 +90,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/users(/.*)?$ { @@ -84,7 +99,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/version(/.*)?$ { @@ -217,22 +232,30 @@ http { proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location ~ ^/ssh/opkssh-callback(/.*)?$ { proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location /ssh/ { @@ -251,15 +274,13 @@ http { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400s; proxy_send_timeout 86400s; diff --git a/docker/nginx.conf b/docker/nginx.conf index a67aaf94..2dddf395 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -26,6 +26,21 @@ http { set_real_ip_from 127.0.0.1; real_ip_header X-Forwarded-For; + map $http_x_forwarded_proto $proxy_x_forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; + } + + map $http_x_forwarded_host $proxy_x_forwarded_host { + default $http_x_forwarded_host; + '' $http_host; + } + + map $http_x_forwarded_port $proxy_x_forwarded_port { + default $http_x_forwarded_port; + '' $server_port; + } + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; @@ -64,7 +79,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/users(/.*)?$ { @@ -73,7 +88,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/version(/.*)?$ { @@ -206,22 +221,30 @@ http { proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location ~ ^/ssh/opkssh-callback(/.*)?$ { proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location /ssh/ { @@ -240,14 +263,13 @@ http { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; + proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400s; proxy_send_timeout 86400s; diff --git a/electron-builder.json b/electron-builder.json index 8137f73d..f58e074a 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -5,24 +5,27 @@ "directories": { "output": "release" }, + "asar": false, "files": [ "dist/**/*", "electron/**/*", "public/**/*", - "!**/node_modules/**/*", "!src/**/*", "!*.md", "!tsconfig*.json", "!vite.config.ts", - "!eslint.config.js" + "!eslint.config.js", + "!node_modules/@napi-rs/canvas*/**/*", + "!node_modules/@rollup/rollup-darwin-*/**/*", + "!node_modules/@rollup/rollup-linux-*/**/*", + "!node_modules/@rollup/rollup-win32-*/**/*" ], - "asarUnpack": ["node_modules/node-fetch/**/*"], "extraMetadata": { "main": "electron/main.cjs" }, "buildDependenciesFromSource": false, "nodeGypRebuild": false, - "npmRebuild": true, + "npmRebuild": false, "win": { "target": [ { @@ -103,7 +106,9 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.inherit.plist", "type": "distribution", - "minimumSystemVersion": "10.15" + "minimumSystemVersion": "10.15", + "mergeASARs": false, + "singleArchFiles": "node_modules/@tailwindcss/oxide-*/**/*" }, "dmg": { "artifactName": "termix_macos_${arch}_dmg.${ext}", @@ -116,7 +121,7 @@ "entitlementsInherit": "build/entitlements.mas.inherit.plist", "hardenedRuntime": false, "gatekeeperAssess": false, - "asarUnpack": ["**/*.node"], + "asar": false, "type": "distribution", "category": "public.app-category.developer-tools", "artifactName": "termix_macos_${arch}_mas.${ext}", diff --git a/electron/main.cjs b/electron/main.cjs index 06dc9ea2..0443c389 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -5,10 +5,76 @@ const { ipcMain, dialog, Menu, + Tray, } = require("electron"); const path = require("path"); const fs = require("fs"); const os = require("os"); +const https = require("https"); +const http = require("http"); +const { URL } = require("url"); +const { fork } = require("child_process"); + +const logFile = path.join(app.getPath("userData"), "termix-main.log"); +function logToFile(...args) { + const timestamp = new Date().toISOString(); + const msg = args + .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) + .join(" "); + const line = `[${timestamp}] ${msg}\n`; + try { + fs.appendFileSync(logFile, line); + } catch { + // ignore + } + console.log(...args); +} + +function httpFetch(url, options = {}) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === "https:"; + const client = isHttps ? https : http; + + const requestOptions = { + method: options.method || "GET", + headers: options.headers || {}, + timeout: options.timeout || 10000, + }; + + if (isHttps) { + requestOptions.rejectUnauthorized = false; + requestOptions.agent = new https.Agent({ + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + }); + } + + const req = client.request(url, requestOptions, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + text: () => Promise.resolve(data), + json: () => Promise.resolve(JSON.parse(data)), + }); + }); + }); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Request timeout")); + }); + + if (options.body) { + req.write(options.body); + } + req.end(); + }); +} if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); @@ -22,10 +88,153 @@ app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; +let backendProcess = null; +let tray = null; +let isQuitting = false; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); +function getBackendEntryPath() { + if (isDev) { + return path.join(appRoot, "dist", "backend", "backend", "starter.js"); + } + // In production, asar is disabled (asar: false in electron-builder.json) + // so backend is directly in appRoot/dist + return path.join(appRoot, "dist", "backend", "backend", "starter.js"); +} + +function getBackendDataDir() { + const userDataPath = app.getPath("userData"); + const dataDir = path.join(userDataPath, "server-data"); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + return dataDir; +} + +function startBackendServer() { + return new Promise((resolve) => { + const entryPath = getBackendEntryPath(); + + logToFile("isDev:", isDev, "appRoot:", appRoot); + logToFile("app.isPackaged:", app.isPackaged); + logToFile("process.env.NODE_ENV:", process.env.NODE_ENV); + + if (!fs.existsSync(entryPath)) { + logToFile("Backend entry not found:", entryPath); + resolve(false); + return; + } + + const dataDir = getBackendDataDir(); + logToFile("Starting embedded backend server..."); + logToFile("Backend entry:", entryPath); + logToFile("Data directory:", dataDir); + logToFile("Backend cwd:", appRoot); + + // Verify all required paths exist + logToFile("Checking paths..."); + logToFile(" entryPath exists:", fs.existsSync(entryPath)); + logToFile(" dataDir exists:", fs.existsSync(dataDir)); + logToFile(" appRoot exists:", fs.existsSync(appRoot)); + + // List contents of dist directory + const distPath = path.join(appRoot, "dist"); + if (fs.existsSync(distPath)) { + logToFile(" dist directory contents:", fs.readdirSync(distPath)); + const backendPath = path.join(distPath, "backend"); + if (fs.existsSync(backendPath)) { + logToFile(" dist/backend contents:", fs.readdirSync(backendPath)); + } + } + + backendProcess = fork(entryPath, [], { + cwd: appRoot, + env: { + ...process.env, + DATA_DIR: dataDir, + NODE_ENV: "production", + ELECTRON_EMBEDDED: "true", + PORT: "30001", + }, + stdio: ["pipe", "pipe", "pipe", "ipc"], + }); + + logToFile("Backend process spawned, pid:", backendProcess.pid); + + let resolved = false; + const readyTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + logToFile("Backend ready timeout (15s), proceeding anyway..."); + resolve(true); + } + }, 15000); + + backendProcess.stdout.on("data", (data) => { + const msg = data.toString().trim(); + logToFile("[backend]", msg); + if (!resolved && msg.includes("started successfully")) { + resolved = true; + clearTimeout(readyTimeout); + logToFile("Backend ready signal received"); + resolve(true); + } + }); + + backendProcess.stderr.on("data", (data) => { + logToFile("[backend:stderr]", data.toString().trim()); + }); + + backendProcess.on("exit", (code, signal) => { + logToFile(`Backend process exited with code ${code}, signal ${signal}`); + backendProcess = null; + if (!resolved) { + resolved = true; + clearTimeout(readyTimeout); + resolve(false); + } + }); + + backendProcess.on("error", (err) => { + logToFile("Failed to start backend process:", err.message); + backendProcess = null; + if (!resolved) { + resolved = true; + clearTimeout(readyTimeout); + resolve(false); + } + }); + }); +} + +function stopBackendServer() { + if (!backendProcess) return; + + console.log("Stopping embedded backend server..."); + + // Use IPC for graceful shutdown (SIGTERM doesn't work on Windows) + try { + backendProcess.send({ type: "shutdown" }); + } catch { + // IPC channel may already be closed + } + + const forceKillTimeout = setTimeout(() => { + if (backendProcess) { + console.log("Force killing backend process..."); + backendProcess.kill("SIGKILL"); + backendProcess = null; + } + }, 5000); + + backendProcess.on("exit", () => { + clearTimeout(forceKillTimeout); + backendProcess = null; + }); +} + const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { console.log("Another instance is already running, quitting..."); @@ -41,6 +250,64 @@ if (!gotTheLock) { }); } +function createTray() { + try { + const { nativeImage } = require("electron"); + + let trayIcon; + if (process.platform === "darwin") { + // macOS: use 16x16 Template image for menu bar + const iconPath = path.join(appRoot, "public", "icons", "16x16.png"); + trayIcon = nativeImage.createFromPath(iconPath); + trayIcon.setTemplateImage(true); + } else if (process.platform === "win32") { + trayIcon = path.join(appRoot, "public", "icon.ico"); + } else { + trayIcon = path.join(appRoot, "public", "icons", "32x32.png"); + } + + tray = new Tray(trayIcon); + tray.setToolTip("Termix"); + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show Window", + click: () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + }, + }, + { + label: "Quit", + click: () => { + isQuitting = true; + app.quit(); + }, + }, + ]); + + tray.setContextMenu(contextMenu); + + tray.on("click", () => { + if (mainWindow) { + if (mainWindow.isVisible()) { + mainWindow.hide(); + } else { + mainWindow.show(); + mainWindow.focus(); + } + } + }); + + console.log("System tray created successfully"); + } catch (err) { + console.error("Failed to create system tray:", err); + // Tray is non-critical; app still works without it + } +} + function createWindow() { const appVersion = app.getVersion(); const electronVersion = process.versions.electron; @@ -71,6 +338,19 @@ function createWindow() { show: true, }); + mainWindow.webContents.session.setPermissionRequestHandler( + (webContents, permission, callback) => { + if ( + permission === "clipboard-read" || + permission === "clipboard-sanitized-write" + ) { + callback(true); + return; + } + callback(true); + }, + ); + if (process.platform !== "darwin") { mainWindow.setMenuBarVisibility(false); } @@ -183,6 +463,13 @@ function createWindow() { console.log("Frontend loaded successfully"); }); + mainWindow.on("close", (event) => { + if (!isQuitting && tray && !tray.isDestroyed()) { + event.preventDefault(); + mainWindow.hide(); + } + }); + mainWindow.on("closed", () => { mainWindow = null; }); @@ -215,65 +502,7 @@ async function fetchGitHubAPI(endpoint, cacheKey) { } try { - let fetch; - try { - fetch = globalThis.fetch || require("node-fetch"); - } catch (e) { - const https = require("https"); - const http = require("http"); - const { URL } = require("url"); - - fetch = (url, options = {}) => { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === "https:"; - const client = isHttps ? https : http; - - const requestOptions = { - method: options.method || "GET", - headers: options.headers || {}, - timeout: options.timeout || 10000, - }; - - if (isHttps) { - requestOptions.rejectUnauthorized = false; - requestOptions.agent = new https.Agent({ - rejectUnauthorized: false, - secureProtocol: "TLSv1_2_method", - checkServerIdentity: () => undefined, - ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH", - honorCipherOrder: true, - }); - } - - const req = client.request(url, requestOptions, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - text: () => Promise.resolve(data), - json: () => Promise.resolve(JSON.parse(data)), - }); - }); - }); - - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Request timeout")); - }); - - if (options.body) { - req.write(options.body); - } - req.end(); - }); - }; - } - - const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { + const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "TermixElectronUpdateChecker/1.0", @@ -358,6 +587,14 @@ ipcMain.handle("get-platform", () => { return process.platform; }); +ipcMain.handle("get-embedded-server-status", () => { + return { + running: backendProcess !== null && !backendProcess.killed, + embedded: !isDev, + dataDir: isDev ? null : getBackendDataDir(), + }; +}); + ipcMain.handle("get-server-config", () => { try { const userDataPath = app.getPath("userData"); @@ -433,67 +670,33 @@ ipcMain.handle("set-setting", (event, key, value) => { } }); +ipcMain.handle("clear-session-cookies", async () => { + try { + const ses = mainWindow?.webContents?.session; + if (ses) { + const cookies = await ses.cookies.get({}); + for (const cookie of cookies) { + const scheme = cookie.secure ? "https" : "http"; + const domain = cookie.domain?.startsWith(".") + ? cookie.domain.slice(1) + : cookie.domain || "localhost"; + const url = `${scheme}://${domain}${cookie.path || "/"}`; + await ses.cookies.remove(url, cookie.name); + } + } + } catch (error) { + console.error("Failed to clear session cookies:", error); + } +}); + ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { - const https = require("https"); - const http = require("http"); - const { URL } = require("url"); - - const fetch = (url, options = {}) => { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === "https:"; - const client = isHttps ? https : http; - - const requestOptions = { - method: options.method || "GET", - headers: options.headers || {}, - timeout: options.timeout || 10000, - }; - - if (isHttps) { - requestOptions.rejectUnauthorized = false; - requestOptions.agent = new https.Agent({ - rejectUnauthorized: false, - secureProtocol: "TLSv1_2_method", - checkServerIdentity: () => undefined, - ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH", - honorCipherOrder: true, - }); - } - - const req = client.request(url, requestOptions, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - text: () => Promise.resolve(data), - json: () => Promise.resolve(JSON.parse(data)), - }); - }); - }); - - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Request timeout")); - }); - - if (options.body) { - req.write(options.body); - } - req.end(); - }); - }; - const normalizedServerUrl = serverUrl.replace(/\/$/, ""); const healthUrl = `${normalizedServerUrl}/health`; try { - const response = await fetch(healthUrl, { + const response = await httpFetch(healthUrl, { method: "GET", timeout: 10000, }); @@ -539,7 +742,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const versionUrl = `${normalizedServerUrl}/version`; - const response = await fetch(versionUrl, { + const response = await httpFetch(versionUrl, { method: "GET", timeout: 10000, }); @@ -656,13 +859,38 @@ function createMenu() { } } -app.whenReady().then(() => { +app.whenReady().then(async () => { + logToFile("=== App ready ==="); + logToFile( + "isDev:", + isDev, + "platform:", + process.platform, + "arch:", + process.arch, + ); createMenu(); + + // Start embedded backend server (skip in dev mode, backend runs separately via npm run dev:backend) + if (!isDev) { + const result = await startBackendServer(); + logToFile("startBackendServer result:", result); + } else { + logToFile( + "Skipping embedded backend (isDev=true) - expecting separate dev:backend process", + ); + } + + createTray(); createWindow(); + logToFile("=== Startup complete ==="); }); app.on("window-all-closed", () => { - app.quit(); + // If tray exists, keep backend alive; otherwise quit normally + if (!tray || tray.isDestroyed()) { + app.quit(); + } }); app.on("activate", () => { @@ -671,8 +899,13 @@ app.on("activate", () => { } }); +app.on("before-quit", () => { + isQuitting = true; +}); + app.on("will-quit", () => { console.log("App will quit..."); + stopBackendServer(); }); process.on("uncaughtException", (error) => { diff --git a/electron/preload.js b/electron/preload.js index ea1f3458..c7670426 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,4 +1,5 @@ const { contextBridge, ipcRenderer } = require("electron"); +const { clipboard } = require("electron"); contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("get-app-version"), @@ -10,7 +11,14 @@ contextBridge.exposeInMainWorld("electronAPI", { getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), + clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); +contextBridge.exposeInMainWorld("electronClipboard", { + writeText: (text) => clipboard.writeText(text), + readText: () => clipboard.readText(), +}); + window.IS_ELECTRON = true; diff --git a/eslint.config.js b/eslint.config.js index f4616740..a7b8196c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint"; import { globalIgnores } from "eslint/config"; export default tseslint.config([ - globalIgnores(["dist"]), + globalIgnores(["dist", "release", "Mobile"]), { files: ["**/*.{ts,tsx}"], extends: [ @@ -19,5 +19,13 @@ export default tseslint.config([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-expressions": "warn", + "no-empty": "warn", + "no-control-regex": "off", + "react-refresh/only-export-components": "warn", + }, }, ]); diff --git a/package-lock.json b/package-lock.json index b75fcc03..75c92b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "1.11.1", + "version": "1.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "1.11.1", + "version": "1.11.2", "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.3.3", @@ -43,7 +43,7 @@ "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-theme-github": "^4.25.4", "@uiw/react-codemirror": "^4.24.1", - "@xterm/addon-clipboard": "^0.1.0", + "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", @@ -107,6 +107,7 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@electron/notarize": "^2.5.0", + "@electron/rebuild": "^3.7.2", "@eslint/js": "^9.34.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", @@ -213,7 +214,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -499,7 +499,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -548,7 +547,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -575,7 +573,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -603,7 +600,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -804,7 +800,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -881,7 +876,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -903,7 +897,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1231,7 +1224,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1508,9 +1500,9 @@ } }, "node_modules/@electron/rebuild": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.0.tgz", - "integrity": "sha512-VW++CNSlZwMYP7MyXEbrKjpzEwhB5kDNbzGtiPEjwYysqyTCF+YbNJ210Dj3AjWsGSV4iEEwNkmJN9yGZmVvmw==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz", + "integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==", "dev": true, "license": "MIT", "dependencies": { @@ -1618,6 +1610,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1639,6 +1632,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2692,8 +2686,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2733,7 +2726,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2765,7 +2757,6 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -2788,7 +2779,6 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -5236,7 +5226,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5400,7 +5389,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5523,7 +5511,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5566,7 +5553,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5577,7 +5563,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5754,7 +5739,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6120,15 +6104,12 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz", - "integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", + "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" - }, - "peerDependencies": { - "@xterm/xterm": "^5.4.0" } }, "node_modules/@xterm/addon-fit": { @@ -6162,8 +6143,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -6198,7 +6178,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6368,6 +6347,35 @@ "electron-builder-squirrel-windows": "26.0.12" } }, + "node_modules/app-builder-lib/node_modules/@electron/rebuild": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.0.tgz", + "integrity": "sha512-VW++CNSlZwMYP7MyXEbrKjpzEwhB5kDNbzGtiPEjwYysqyTCF+YbNJ210Dj3AjWsGSV4iEEwNkmJN9yGZmVvmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/app-builder-lib/node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -6626,7 +6634,6 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6769,7 +6776,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7776,7 +7782,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7853,7 +7858,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7886,7 +7892,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -8326,7 +8331,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -8437,7 +8441,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8812,6 +8817,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8832,6 +8838,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8847,6 +8854,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8857,6 +8865,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -9118,7 +9127,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11136,7 +11144,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -12719,6 +12726,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -14756,6 +14764,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -14773,6 +14782,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -15258,7 +15268,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15281,7 +15290,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15340,7 +15348,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -15488,7 +15495,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15711,8 +15717,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17197,6 +17202,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -17237,6 +17243,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -17251,6 +17258,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -17355,7 +17363,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17561,7 +17568,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17984,7 +17990,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -18076,7 +18081,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index e2952faf..6f960e24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "1.11.1", + "version": "1.11.2", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", @@ -22,12 +22,13 @@ "generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js", "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", - "build:win-portable": "npm run build && electron-builder --win --dir", - "build:win-installer": "npm run build && electron-builder --win --publish=never", - "build:linux-portable": "npm run build && electron-builder --linux --dir", - "build:linux-appimage": "npm run build && electron-builder --linux AppImage", - "build:linux-targz": "npm run build && electron-builder --linux tar.gz", - "build:mac": "npm run build && electron-builder --mac --universal" + "electron:rebuild": "electron-rebuild -f -w better-sqlite3", + "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir", + "build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never", + "build:linux-portable": "npm run build && npm run electron:rebuild && electron-builder --linux --dir", + "build:linux-appimage": "npm run build && npm run electron:rebuild && electron-builder --linux AppImage", + "build:linux-targz": "npm run build && npm run electron:rebuild && electron-builder --linux tar.gz", + "build:mac": "npm run build && npm run electron:rebuild && electron-builder --mac --universal" }, "dependencies": { "@codemirror/autocomplete": "^6.18.7", @@ -65,7 +66,7 @@ "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-theme-github": "^4.25.4", "@uiw/react-codemirror": "^4.24.1", - "@xterm/addon-clipboard": "^0.1.0", + "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", @@ -129,6 +130,7 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@electron/notarize": "^2.5.0", + "@electron/rebuild": "^3.7.2", "@eslint/js": "^9.34.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", diff --git a/public/sw.js b/public/sw.js index 462abdfb..b1d1799f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -54,6 +54,13 @@ self.addEventListener("fetch", (event) => { return; } + if ( + url.pathname.startsWith("/ssh/opkssh-chooser/") || + url.pathname.startsWith("/ssh/opkssh-callback/") + ) { + return; + } + if (url.origin !== self.location.origin) { return; } diff --git a/readme/README-AR.md b/readme/README-AR.md new file mode 100644 index 00000000..43a9f3f6 --- /dev/null +++ b/readme/README-AR.md @@ -0,0 +1,187 @@ +# إحصائيات المستودع + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ تم تحقيقه في 1 سبتمبر 2025 +

+ +
+

+ + Termix Banner +

+ +إذا كنت ترغب في ذلك، يمكنك دعم المشروع هنا!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# نظرة عامة + +

+ + Termix Banner +

+ +Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، وقدرات إنشاء أنفاق SSH، وإدارة الملفات عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات. + +# الميزات + +- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى +- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r +- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo +- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها +- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH +- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux +- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم +- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار +- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً +- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات +- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات +- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS +- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين الوضع الداكن أو الفاتح. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة +- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)) +- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android +- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة +- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً +- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال +- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح +- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها +- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# الميزات المخططة + +راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# التثبيت + +الأجهزة المدعومة: + +- الموقع الإلكتروني (أي متصفح حديث على أي منصة مثل Chrome و Safari و Firefox) (يتضمن دعم PWA) +- Windows (x64/ia32) + - نسخة محمولة + - مثبت MSI + - مدير حزم Chocolatey +- Linux (x64/ia32) + - نسخة محمولة + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 على الإصدار 12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (الإصدار 15.1+) + - Apple App Store + - IPA +- Android (الإصدار 7.0+) + - Google Play Store + - APK + +قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# الرعاة + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# الدعم + +إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. +يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول. + +# لقطات الشاشة + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي. + +# الترخيص + +موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف LICENSE لمزيد من المعلومات. diff --git a/readme/README-CN.md b/readme/README-CN.md new file mode 100644 index 00000000..b4f73562 --- /dev/null +++ b/readme/README-CN.md @@ -0,0 +1,189 @@ +# 仓库统计 + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + DeutsIPAh DeutsIPAh · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +DisIPAord + +

+ Repo of the Day AIPAhievement +
+ 2025年9月1日获得 +

+ +
+

+ + Termix Banner +

+ +如果你愿意,可以在这里支持这个项目!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoIPAolor=white)](https://github.IPAom/sponsors/LukeGus) + +# 概览 + +

+ + Termix Banner +

+ +Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix +提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。 + +# 功能 + +- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件 +- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能 +- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件 +- **DoIPAker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 doIPAker exeIPA 终端控制容器。它不是用来替代 Portainer 或 DoIPAkge,而是用于简单管理你的容器而不是创建它们。 +- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥 +- **服务器统计** - 在任何 SSH 服务器上查看 IPAPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息 +- **仪表板** - 在仪表板上一目了然地查看服务器信息 +- **RBAIPA** - 创建角色并在用户/角色之间共享主机 +- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDIPA 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDIPA/本地帐户链接在一起。 +- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://doIPAs.termix.site/seIPAurity)了解更多信息。 +- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 +- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 +- **现代用户界面** - 使用 ReaIPAt、Tailwind IPASS 和 ShadIPAn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。 +- **语言** - 内置支持约 30 种语言(由 [IPArowdin](https://doIPAs.termix.site/translations) 管理) +- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 maIPAOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。 +- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。 +- **命令历史** - 自动完成并查看以前运行的 SSH 命令 +- **快速连接** - 无需保存连接数据即可连接到服务器 +- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接 +- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOIPAKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.IPAom/openpubkey/opkssh)等。 +- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示 +- **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态 + +# 计划功能 + +查看 [项目](https://github.IPAom/orgs/Termix-SSH/projeIPAts/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.IPAom/Termix-SSH/Termix/blob/main/IPAONTRIBUTING.md)。 + +# 安装 + +支持的设备: + +- 网站(任何平台上的任何现代浏览器,如 IPAhrome、Safari 和 Firefox)(包括 PWA 支持) +- Windows(x64/ia32) + - 便携版 + - MSI 安装程序 + - IPAhoIPAolatey 软件包管理器 +- Linux(x64/ia32) + - 便携版 + - AUR + - AppImage + - Deb + - Flatpak +- maIPAOS(x64/ia32 on v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS(v15.1+) + - Apple App Store + - IPA +- Android(v7.0+) + - Google Play 商店 + - APK + +访问 Termix [文档](https://doIPAs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 DoIPAker IPAompose 文件: + +```yaml +serviIPAes: + termix: + image: ghIPAr.io/lukegus/termix:latest + IPAontainer_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: loIPAal +``` + +# 赞助商 + +

+ + DigitalOIPAean + +          + + IPArowdin + +          + + IPArowdin + +          + + IPArowdin + +

+ +# 支持 + +如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.IPAom/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。 +请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [DisIPAord](https://disIPAord.gg/jVQGdvHDrf) 服务器并访问支持 +频道,但响应时间可能较长。 + +# 展示 + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfIPAK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +某些视频和图像可能已过时或可能无法完美展示功能。 + +# 许可证 + +根据 ApaIPAhe LiIPAense Version 2.0 发布。更多信息请参见 LIIPAENSE。 diff --git a/readme/README-DE.md b/readme/README-DE.md new file mode 100644 index 00000000..df6b83d8 --- /dev/null +++ b/readme/README-DE.md @@ -0,0 +1,187 @@ +# Repo-Statistiken + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Erreicht am 1. September 2025 +

+ +
+

+ + Termix Banner +

+ +Wenn Sie möchten, können Sie das Projekt hier unterstützen!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Überblick + +

+ + Termix Banner +

+ +Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, SSH-Tunneling-Funktionen, Remote-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen. + +# Funktionen + +- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten +- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen +- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung. +- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. +- **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren +- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen +- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen +- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen +- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen. +- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security). +- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren +- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen +- **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen dunklem oder hellem Modus. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen. +- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations)) +- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS), PWA und dedizierte Mobil-/Tablet-App für iOS und Android. +- **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus. +- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle +- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen +- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen +- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw. +- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Geplante Funktionen + +Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) für alle geplanten Funktionen. Wenn Sie beitragen möchten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Installation + +Unterstützte Geräte: + +- Website (jeder moderne Browser auf jeder Plattform wie Chrome, Safari und Firefox) (einschließlich PWA-Unterstützung) +- Windows (x64/ia32) + - Portabel + - MSI-Installationsprogramm + - Chocolatey-Paketmanager +- Linux (x64/ia32) + - Portabel + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 ab v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Sponsoren + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Support + +Wenn Sie Hilfe benötigen oder eine Funktion für Termix anfragen möchten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. +Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf Englisch. Sie können auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings können die Antwortzeiten dort länger sein. + +# Screenshots + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Einige Videos und Bilder können veraltet sein oder Funktionen möglicherweise nicht perfekt darstellen. + +# Lizenz + +Verteilt unter der Apache License Version 2.0. Siehe LICENSE für weitere Informationen. diff --git a/readme/README-ES.md b/readme/README-ES.md new file mode 100644 index 00000000..328c9de3 --- /dev/null +++ b/readme/README-ES.md @@ -0,0 +1,187 @@ +# Estadísticas del Repositorio + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Logrado el 1 de septiembre de 2025 +

+ +
+

+ + Termix Banner +

+ +Si lo desea, puede apoyar el proyecto aquí.\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Descripción General + +

+ + Termix Banner +

+ +Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, capacidades de túneles SSH, gestión remota de archivos y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas. + +# Características + +- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes +- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r +- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. +- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal Docker Exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. +- **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH +- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux +- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard +- **RBAC** - Cree roles y comparta hosts entre usuarios/roles +- **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí. +- **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información. +- **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos +- **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS +- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre modo oscuro o claro. Use rutas URL para abrir cualquier conexión en pantalla completa. +- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)) +- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS), PWA y aplicación dedicada para móviles/tablets en iOS y Android. +- **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos. +- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente +- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión +- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado +- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc. +- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Características Planeadas + +Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las características planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Instalación + +Dispositivos soportados: + +- Sitio web (cualquier navegador moderno en cualquier plataforma como Chrome, Safari y Firefox) (incluye soporte PWA) +- Windows (x64/ia32) + - Portable + - Instalador MSI + - Gestor de paquetes Chocolatey +- Linux (x64/ia32) + - Portable + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 en v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Patrocinadores + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Soporte + +Si necesita ayuda o desea solicitar una función para Termix, visite la página de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesión y pulse `New Issue`. +Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito en inglés. También puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser más largos. + +# Capturas de Pantalla + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Algunos videos e imágenes pueden estar desactualizados o no mostrar perfectamente las características. + +# Licencia + +Distribuido bajo la Licencia Apache Versión 2.0. Consulte LICENSE para más información. diff --git a/readme/README-FR.md b/readme/README-FR.md new file mode 100644 index 00000000..afe7791f --- /dev/null +++ b/readme/README-FR.md @@ -0,0 +1,186 @@ +# Statistiques du dépôt + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Obtenu le 1er septembre 2025 +

+ +
+

+ + Termix Banner +

+ +Si vous le souhaitez, vous pouvez soutenir le projet ici !\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Présentation + +

+ + Termix Banner +

+ +Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, des capacités de tunneling SSH, la gestion de fichiers à distance, et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes. + +# Fonctionnalités + +- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants +- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r +- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo +- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer +- **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH +- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux +- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord +- **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles +- **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble +- **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails +- **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers +- **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS +- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez entre un thème sombre ou clair. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran +- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations)) +- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS), PWA, et application mobile/tablette dédiée pour iOS et Android +- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts +- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées +- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion +- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier +- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc. +- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Fonctionnalités prévues + +Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalités prévues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Installation + +Appareils supportés : + +- Site web (tout navigateur moderne sur toute plateforme comme Chrome, Safari et Firefox) (support PWA inclus) +- Windows (x64/ia32) + - Portable + - Installateur MSI + - Gestionnaire de paquets Chocolatey +- Linux (x64/ia32) + - Portable + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 sur v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose : + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Sponsors + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Support + +Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez être aussi détaillé que possible dans votre issue, de préférence rédigée en anglais. Vous pouvez également rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de réponse peuvent être plus longs. + +# Captures d'écran + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Certaines vidéos et images peuvent être obsolètes ou ne pas présenter parfaitement les fonctionnalités. + +# Licence + +Distribué sous la licence Apache Version 2.0. Consultez LICENSE pour plus d'informations. diff --git a/readme/README-HI.md b/readme/README-HI.md new file mode 100644 index 00000000..140d0c6e --- /dev/null +++ b/readme/README-HI.md @@ -0,0 +1,187 @@ +# रिपॉजिटरी आँकड़े + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ 1 सितंबर, 2025 को प्राप्त +

+ +
+

+ + Termix Banner +

+ +यदि आप चाहें, तो आप यहाँ प्रोजेक्ट को सपोर्ट कर सकते हैं!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# अवलोकन + +

+ + Termix Banner +

+ +Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, SSH टनलिंग क्षमताएँ, रिमोट फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है। + +# विशेषताएँ + +- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है +- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ +- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें +- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है +- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें +- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें +- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें +- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें +- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें +- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें +- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें +- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन +- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। डार्क या लाइट मोड UI के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें +- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित) +- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध +- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ +- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य +- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें +- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें +- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट +- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# नियोजित विशेषताएँ + +सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें। + +# इंस्टॉलेशन + +समर्थित डिवाइस: + +- वेबसाइट (किसी भी प्लेटफ़ॉर्म पर कोई भी आधुनिक ब्राउज़र जैसे Chrome, Safari, और Firefox) (PWA सपोर्ट सहित) +- Windows (x64/ia32) + - पोर्टेबल + - MSI इंस्टॉलर + - Chocolatey पैकेज मैनेजर +- Linux (x64/ia32) + - पोर्टेबल + - AUR + - AppImage + - Deb + - Flatpak +- macOS (v12.0+ पर x64/ia32) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# प्रायोजक + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# सहायता + +यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। +कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है। + +# स्क्रीनशॉट + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं। + +# लाइसेंस + +Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए LICENSE देखें। diff --git a/readme/README-IT.md b/readme/README-IT.md new file mode 100644 index 00000000..ed950774 --- /dev/null +++ b/readme/README-IT.md @@ -0,0 +1,187 @@ +# Statistiche Repo + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Ottenuto il 1 settembre 2025 +

+ +
+

+ + Termix Banner +

+ +Se lo desideri, puoi supportare il progetto qui!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Panoramica + +

+ + Termix Banner +

+ +Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, funzionalità di tunneling SSH, gestione remota dei file e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme. + +# Funzionalità + +- **Accesso Terminale SSH** - Terminale completo con supporto schermo divIPA (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni +- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r +- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. +- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. +- **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH +- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux +- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard +- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli +- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. +- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni. +- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file +- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS +- **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra modalità scura o chiara. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero. +- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)) +- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS), PWA e app dedicata per mobile/tablet su iOS e Android. +- **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti. +- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza +- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione +- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera +- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc. +- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Funzionalità Pianificate + +Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Installazione + +Dispositivi Supportati: + +- Sito web (qualsiasi browser moderno su qualsiasi piattaforma come Chrome, Safari e Firefox) (include supporto PWA) +- Windows (x64/ia32) + - Portable + - MSI Installer + - Chocolatey Package Manager +- Linux (x64/ia32) + - Portable + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 su v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Sponsor + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Supporto + +Se hai bIPAgno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. +Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi. + +# Screenshot + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità. + +# Licenza + +Distribuito sotto la Licenza Apache Versione 2.0. Consulta LICENSE per maggiori informazioni. diff --git a/readme/README-JA.md b/readme/README-JA.md new file mode 100644 index 00000000..d615845f --- /dev/null +++ b/readme/README-JA.md @@ -0,0 +1,186 @@ +# リポジトリ統計 + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ 2025年9月1日に達成 +

+ +
+

+ + Termix Banner +

+ +プロジェクトを支援していただける方はこちらからどうぞ!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# 概要 + +

+ + Termix Banner +

+ +Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、SSHトンネリング機能、リモートファイル管理、その他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。 + +# 機能 + +- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応 +- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応 +- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行 +- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています +- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化 +- **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示 +- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認 +- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有 +- **ユーザー認証** - 管理者コントロールとOIDCおよび2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携 +- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください +- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポート +- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理 +- **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ダーク/ライトモードの切り替え対応。URLルートで任意の接続をフルスクリーンで開くことが可能 +- **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理) +- **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能 +- **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行 +- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示 +- **クイック接続** - 接続データを保存せずにサーバーに接続 +- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス +- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応 +- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化 +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# 予定されている機能 + +すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。 + +# インストール + +対応デバイス: + +- Webサイト(Chrome、Safari、Firefoxなど、あらゆるプラットフォームのモダンブラウザ)(PWA対応) +- Windows (x64/ia32) + - ポータブル版 + - MSIインストーラー + - Chocolateyパッケージマネージャー +- Linux (x64/ia32) + - ポータブル版 + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32、v12.0以降) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1以降) + - Apple App Store + - IPA +- Android (v7.0以降) + - Google Play Store + - APK + +すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。以下はDocker Composeファイルのサンプルです: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# スポンサー + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# サポート + +Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。 + +# スクリーンショット + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +一部の動画や画像は古い場合や、機能を完全に紹介していない場合があります。 + +# ライセンス + +Apache License Version 2.0のもとで配布されています。詳細はLICENSEをご覧ください。 diff --git a/readme/README-KO.md b/readme/README-KO.md new file mode 100644 index 00000000..8cd37b75 --- /dev/null +++ b/readme/README-KO.md @@ -0,0 +1,186 @@ +# 리포지토리 통계 + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ 2025년 9월 1일 달성 +

+ +
+

+ + Termix Banner +

+ +프로젝트를 후원하고 싶으시다면 여기에서 지원해 주세요!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# 개요 + +

+ + Termix Banner +

+ +Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, SSH 터널링 기능, 원격 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다. + +# 기능 + +- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원 +- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원 +- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행 +- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다 +- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화 +- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시 +- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인 +- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유 +- **사용자 인증** - 관리자 제어와 OIDC 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동 +- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요 +- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기 +- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리 +- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 다크 또는 라이트 모드 기반 UI 선택. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능 +- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리) +- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공 +- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행 +- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회 +- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속 +- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근 +- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원 +- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화 +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# 계획된 기능 + +모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요. + +# 설치 + +지원 기기: + +- 웹사이트 (Chrome, Safari, Firefox 등 모든 플랫폼의 최신 브라우저) (PWA 지원 포함) +- Windows (x64/ia32) + - 포터블 + - MSI 설치 프로그램 + - Chocolatey 패키지 관리자 +- Linux (x64/ia32) + - 포터블 + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32, v12.0 이상) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1 이상) + - Apple App Store + - IPA +- Android (v7.0 이상) + - Google Play Store + - APK + +모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# 스폰서 + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# 지원 + +Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다. + +# 스크린샷 + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다. + +# 라이선스 + +Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 LICENSE를 참조하세요. diff --git a/readme/README-PT.md b/readme/README-PT.md new file mode 100644 index 00000000..cf5d735d --- /dev/null +++ b/readme/README-PT.md @@ -0,0 +1,187 @@ +# Estatísticas do Repositório + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Conquistado em 1 de setembro de 2025 +

+ +
+

+ + Termix Banner +

+ +Se desejar, você pode apoiar o projeto aqui!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Visão Geral + +

+ + Termix Banner +

+ +Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, capacidades de tunelamento SSH, gerenciamento remoto de arquivos e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas. + +# Funcionalidades + +- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes +- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r +- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. +- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los. +- **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH +- **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux +- **Dashboard** - Visualize informações do servidor de relance no seu dashboard +- **RBAC** - Crie funções e compartilhe hosts entre usuários/funções +- **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si. +- **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações. +- **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos +- **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS +- **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre modo escuro ou claro. Use rotas de URL para abrir qualquer conexão em tela cheia. +- **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)) +- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS), PWA e aplicativo dedicado para celular/tablet para iOS e Android. +- **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos. +- **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente +- **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão +- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado +- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc. +- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Funcionalidades Planejadas + +Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se você deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Instalação + +Dispositivos suportados: + +- Website (qualquer navegador moderno em qualquer plataforma como Chrome, Safari e Firefox) (inclui suporte PWA) +- Windows (x64/ia32) + - Portátil + - Instalador MSI + - Gerenciador de pacotes Chocolatey +- Linux (x64/ia32) + - Portátil + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 em v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Patrocinadores + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Suporte + +Se você precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a página de [Issues](https://github.com/Termix-SSH/Support/issues), faça login e clique em `New Issue`. +Por favor, seja o mais detalhado possível no seu relato, preferencialmente escrito em inglês. Você também pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porém, os tempos de resposta podem ser mais longos. + +# Capturas de Tela + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades. + +# Licença + +Distribuído sob a Licença Apache Versão 2.0. Consulte LICENSE para mais informações. diff --git a/readme/README-RU.md b/readme/README-RU.md new file mode 100644 index 00000000..86af78f9 --- /dev/null +++ b/readme/README-RU.md @@ -0,0 +1,187 @@ +# Статистика репозитория + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Достигнуто 1 сентября 2025 года +

+ +
+

+ + Termix Banner +

+ +Если хотите, вы можете поддержать проект здесь!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Обзор + +

+ + Termix Banner +

+ +Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, возможности SSH-туннелирования, удалённое управление файлами и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ. + +# Возможности + +- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты +- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r +- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo +- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием +- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей +- **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux +- **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом +- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей +- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов +- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security) +- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера +- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS +- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между тёмной и светлой темой. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме +- **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations)) +- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS), PWA и специализированное мобильное/планшетное приложение для iOS и Android +- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах +- **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд +- **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения +- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры +- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др. +- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Запланированные функции + +Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Установка + +Поддерживаемые устройства: + +- Веб-сайт (любой современный браузер на любой платформе, включая Chrome, Safari и Firefox) (включая поддержку PWA) +- Windows (x64/ia32) + - Портативная версия + - Установщик MSI + - Менеджер пакетов Chocolatey +- Linux (x64/ia32) + - Портативная версия + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32, версия 12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (версия 15.1+) + - Apple App Store + - IPA +- Android (версия 7.0+) + - Google Play Store + - APK + +Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Спонсоры + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Поддержка + +Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. +Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше. + +# Скриншоты + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. + +# Лицензия + +Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле LICENSE. diff --git a/readme/README-TR.md b/readme/README-TR.md new file mode 100644 index 00000000..d52345e5 --- /dev/null +++ b/readme/README-TR.md @@ -0,0 +1,187 @@ +# Repo İstatistikleri + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ 1 Eylül 2025'te kazanıldı +

+ +
+

+ + Termix Banner +

+ +Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Genel Bakış + +

+ + Termix Banner +

+ +Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, SSH tünelleme yetenekleri, uzak dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir. + +# Özellikler + +- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir +- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle +- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın. +- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır. +- **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin +- **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin +- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin +- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın +- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın. +- **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın. +- **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın +- **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi +- **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Karanlık veya açık tema arasında seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın. +- **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir) +- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir. +- **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın. +- **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin +- **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın +- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin +- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler. +- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Planlanan Özellikler + +Tüm planlanan özellikler için [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasına bakın. Katkıda bulunmak istiyorsanız, [Katkıda Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasına bakın. + +# Kurulum + +Desteklenen Cihazlar: + +- Web sitesi (Chrome, Safari ve Firefox gibi herhangi bir platformda herhangi bir modern tarayıcı) (PWA desteği dahil) +- Windows (x64/ia32) + - Taşınabilir + - MSI Yükleyici + - Chocolatey Paket Yöneticisi +- Linux (x64/ia32) + - Taşınabilir + - AUR + - AppImage + - Deb + - Flatpak +- macOS (v12.0+ üzerinde x64/ia32) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Sponsorlar + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Destek + +Termix ile ilgili yardıma ihtiyacınız varsa veya bir özellik talep etmek istiyorsanız, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasını ziyaret edin, giriş yapın ve `New Issue` butonuna basın. +Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce olarak. Ayrıca [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katılabilir ve destek kanalını ziyaret edebilirsiniz, ancak yanıt süreleri daha uzun olabilir. + +# Ekran Görüntüleri + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir. + +# Lisans + +Apache Lisansı Sürüm 2.0 altında dağıtılmaktadır. Daha fazla bilgi için LICENSE dosyasına bakın. diff --git a/readme/README-VI.md b/readme/README-VI.md new file mode 100644 index 00000000..634e3b36 --- /dev/null +++ b/readme/README-VI.md @@ -0,0 +1,187 @@ +# Thống Kê Repo + +

+ English English · + 中文 中文 · + 日本語 日本語 · + 한국어 한국어 · + Français Français · + Deutsch Deutsch · + Español Español · + Português Português · + Русский Русский · + العربية العربية · + हिन्दी हिन्दी · + Türkçe Türkçe · + Tiếng Việt Tiếng Việt · + Italiano Italiano +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) +Discord + +

+ Repo of the Day Achievement +
+ Đạt được vào ngày 1 tháng 9 năm 2025 +

+ +
+

+ + Termix Banner +

+ +Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Tổng Quan + +

+ + Termix Banner +

+ +Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, khả năng tạo đường hầm SSH, quản lý tệp từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng. + +# Tính Năng + +- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác +- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r +- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo. +- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng. +- **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH +- **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux +- **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn +- **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò +- **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau. +- **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm. +- **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp +- **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS +- **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa giao diện chế độ tối hoặc sáng. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình. +- **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations)) +- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android. +- **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở. +- **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó +- **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối +- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím +- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v. +- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái +- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile + +# Tính Năng Dự Kiến + +Xem [Dự Án](https://github.com/orgs/Termix-SSH/projects/2) để biết tất cả các tính năng dự kiến. Nếu bạn muốn đóng góp, xem [Đóng Góp](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + +# Cài Đặt + +Thiết Bị Được Hỗ Trợ: + +- Trang web (bất kỳ trình duyệt hiện đại nào trên bất kỳ nền tảng nào như Chrome, Safari và Firefox) (bao gồm hỗ trợ PWA) +- Windows (x64/ia32) + - Portable + - MSI Installer + - Chocolatey Package Manager +- Linux (x64/ia32) + - Portable + - AUR + - AppImage + - Deb + - Flatpak +- macOS (x64/ia32 trên v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS/iPadOS (v15.1+) + - Apple App Store + - IPA +- Android (v7.0+) + - Google Play Store + - APK + +Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix-data:/app/data + environment: + PORT: "8080" + +volumes: + termix-data: + driver: local +``` + +# Nhà Tài Trợ + +

+ + DigitalOcean + +          + + Crowdin + +          + + Crowdin + +          + + Crowdin + +

+ +# Hỗ Trợ + +Nếu bạn cần trợ giúp hoặc muốn yêu cầu tính năng với Termix, hãy truy cập trang [Vấn Đề](https://github.com/Termix-SSH/Support/issues), đăng nhập và nhấn `New Issue`. +Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết bằng tiếng Anh. Bạn cũng có thể tham gia máy chủ [Discord](https://discord.gg/jVQGdvHDrf) và truy cập kênh hỗ trợ, tuy nhiên thời gian phản hồi có thể lâu hơn. + +# Ảnh Chụp Màn Hình + +[![YouTube](../repo-images/YouTube.jpg)](https://youtu.be/sjKIqfCK0NY) + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 +

+ +

+ Termix Demo 5 + Termix Demo 6 +

+ +

+ Termix Demo 7 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 10 +

+ +

+ Termix Demo 11 + Termix Demo 12 +

+ +Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng. + +# Giấy Phép + +Được phân phối theo Giấy Phép Apache Phiên Bản 2.0. Xem LICENSE để biết thêm thông tin. diff --git a/repo-images/YouTube.jpg b/repo-images/YouTube.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d37d0ead31a1bfcadf74f2f949b8584818618c67 GIT binary patch literal 427567 zcmbq*2|QHq_x~LuLyI9krNSr`pGuMzgwZN3Xz!CUN=2(gcJ4@}MTV3%8QLUiRAh^x z#gH^a*)t4{oiQ`adi|d}w0*wY@B96KzOV1gOmpw^oadbPd7tw<_nbRiGt7e2)~&Hy z17R=_gaQA9hU=k~;M@P-f9N3pert|9JnAu4N|~hu?mXVF=G@J!PUXwcSuov%*5#?%at@X?ZwUT)La-E zov5t8;#uJzn=ATfn(jM!T4n5{$y!sU&YEpt_~)EOiaY~8kf z$IicYx$Zx3(9QkOVK47fKE8hb0bys(o(n&J;o_B;tJh+$-?(|}?!EgD9wt3Xe*FAJ zddADltn8fEZ{EHuVicFW|5RD^`Ac<8ZC!H_M!^^SAFHl7X1a;e#O0fCdp$K~EDTlFToIl4tWZVYbaVe7 z`%YGjoiuY%^DHj9H2KQ@Zwov9KdtP?!hWu+4jPZe0OMiRAtHomOh5bW#CesOp#=i| zQ?f;%$N1eFLN8|OxjvGavE%dd9x0UcUn@j3#Q0Ws4BR|13+WpehNeQqpC3uWRUD*4 zS}iriaE0rR4!n}t4v67s8~pXxFIZ4g`ALglE9*RXEc7&JmbGaZ)dP9P8Aup244qlW zme5P3?igaTdKMUA7!va#djEypgl1%=Xk-qC!<*z`hyHpk!Rd&Gp|0{V8U1mO z6WH1T>|}P_P{P=r=4Ge-aD170hs^}}u$3Z5%rK;UjWGmY7nUo)uVdfOf$aV!R+Z~8xC8(}(cb+__ z;IxW%xk4fKW3$>sI|hN%BprmhdMx#QJ+*=(Ogl|ZdomVbd=a%v^j?h~2B(NfZMMPm zZ!8~%V&Qn4U=c*^Kf4D$5#kCK4L7Ug=(sXkh(%<)aK-UlTWYCvBSh#wvxh2jqm&Lq zG;r(cCMgfc1M_3iK}8%2ujdwoKRutGD^h?5x7gsM(>4x(5;zbyadZ+F}I5knR|{ z0e>g~>IXxc3MuWVx#jck4_*M@lv^`VswlHT!WRL}XrP*D$2gpxAKnkfF2|zS04lc~ zSR`K@3J6}`s6vKRr1Xb|=*7cOwg?8ZGE*UAISvRkp(6nMh@z2TE^1$Ia}%JKk7P~1 z8SN3>SRU#>P>{NBEzx*H`Ab(m4%rV29$}gQP6E_>$etq;q4v3juJS3Ig=Zr(N3erR zIt-D(tte8#+(e$JT!En2=o5k1qL>9s2HH31G(qHsY4on8*x!^n2+L82v}ziHv61j} z+3FFAc^{CK!_dV9Mc}ow`+$SeWdZ1v#~u1Z`=W=TYGf;ls>T2}E~#^j{2sW0_yEEc z?pPV7OJqLv1oN$~KnQKJG0>m=tIInR0{8R8KR_UZ0i%p6q#a{>dj7Ma1;Hk=npD`!{|(p+VVP9!%YfJ>G`o6H1t?j58C!VT27PXO&rspSI~4IAYWAk$Ld z9Z%yo$zbX_hy+lca~0dU5$Jq!5Yk8F06|fcz)XEPMJwkb%>>#2j&K<;2(T}XO>H-X zfIWeG(gDu{R|C!iASn4JR~vW81Q7irxeEY7LtqQQXUD_076{nlXd7iq4PV&@L|;6D zHdOusS6&G*saYg0F3AVP84>6;iznsGBh!z9yz@Mu11cAm8jZ9#D-(Y4l z67V~yP^12E^AWBaBC1yl*Z`d<5ZiA`3yGgb*rkGf5TBqgKq(d|2&GuW8v=I1vj{Z7 zJ30PPN2*5MM?O^b5UvaOuDTDgjmoBS<#^EWhQkG<{t;RGa+V?%@aylCf0MWyuqq@c z^ALTp+|^dsa@=&^5>W31SRaf)s4b(*X�cBljMEaXEqNkW`NUA|FZe)H;h%nK+`+ zuIEZaxMAqCT<bYcUJ zOK33!h8&xN^b*x&UUC(011kOoA}X5JKDOk}+$OHiQC0rbacFQLPvJ{F? z7DwF<>FZ1fwER7gqgbEQB?;pZ*kCE!s=ZOqQb)r+^MGsz2n7C#~f_oqsD=pvVr z8fDw%D+i(J1AG|`sY`cDv-pK19>@hy`8W@m3>v%0R@5QC!77v(h4=w8pxhVW$|aPe zjHQyp2Lc;{Pz7|wk}^g(`@5<@dhuOgx&QkLcFFG zz#zZeHnL6;g)2wc95ng+PX6B&h;pvm-H_Gp3d>ne&#jxqY>;OVuR&|RD(B#)Ik3~w z_y%&8rqAvuo##H{JR{PuBg&D7J%CTnuaFHdTuS0G3FncAze<8GfY6NP5j1Txl9b`YaP_;G4DZo)V*Y_nc9(8G3&?{^zlwls^{!f zz;r}r8PxFNZ?d?3Ho8xCu6TU$9@#1f4b3+bltirpM|X(F99j(-+(BNKD~LFfd%Q)E z|8IZdJ$2MqL&oud=}m2L|NS4^)gS=`k=Z-sAlV@N!&R0+8XV$q+hP4CO-%QW%&0?YhGXxIHL0X^35TH=8|}DwBh2}+Nz_dY&PB*WI;*BV z0WOFo&vOz!v73>CDPinp1HO+>$J8wM*Dm)@-0?6lAixXIDbDnfU$jwU)EE}KGU*#p zjNErr0{4eWl0}q$5FREL%BlbcH|OvqR1p;R?w;hL)j)b5EIdeGpean(O~h9L?s+_L*>=Gw=W=0hmVNq_ghH z=XwYrwTqZVza@9ET{xRCE(ozG-K)6s9RQQ0q6v|TJJNz2LCHhKo$#6(NqFp1Lp zr_>gN0%Pf{1i?1x+F=L_`2WXfxJz3>B0!-HLn&y&5sU8y3VRL6>+fqMHX9KdNM&fY zX@tE?W7z^y{YOzy!AI2$`K80BY+Hy436KzcQVnKwfgt>2<{ce0;MU7_qMJ4FGY(w1 zsLw-qaAYCPK*0rwpWK_luYfr<0dMcRC)@(!!1P~cLDRc**b`oin)Mg6I0mmp$a2-8 zDo6w>P#7^>6>wN`xni0ybv=0nuN~~2L;_7r&0kHnUXyPq@R?dj9{h15wC}L9US!2Z z@+(gU$driWp?ABzua0|sWQCIh0q^MSp`su($ENIz_qykKgH^DWcVISp$wfv zqm2=%m)125y+vcO%3(l_FbSFc6O7*tg`!uknY)9-&^eC*kc7udjAej1zR06Km?Mcx z0DCL>bO0{$H1RtCs}-_wczL$|`%PWZ$P}4j9=-Xtl#B|~ z=JMx|A8Xi3#d2BmFU)MD2_(pIe;-oF)g3=#K$GZ-%Lglww=TrDu{j{ zLj%rMMLdrrjJofrwkm(DH!;^&0;b;7((Rd!7pgHjQ2!v^LVwS#AqdrwtPy+hQMo{v z`bBzrEn(;rn%<5&EnzZ7I!+WOeUvAWR+4VWJydRH3>WoT7nFTSN$tas6*(wm-u44y zwvHD^AUnZE#wS7bHN!Eo*NSBf?U(uUL3aO%yfDKmVA+fvhgV6G^j|~E4NwmS26|TO&)+MP@2OPaWA}Jk z(h*ewgvnK9T+N4LSw9x|&&Nldp3<8jwdE_3)lf&pw-6tppx6_6{vM);rX$w_0n!u* zpUBvj-ukTog+!*Dp?>ly*w)HzBRBU-Aq8xx^nivsDQKeh)24tN5`3J4@uSpnfb;?X z4ULQ=D}{b~svLcQS$8MIA)4?_dDGQuWo5)MP;yF&ouM|kK@X`^&O{XQ&M zU}(jbel!UG&@N!Zdy+>W2ee5T<*8*d)0IAbLrXEE$7I{i?6=;^#5oZD@`X-Kut%o%-%jhM-vIQg;aWY8>MZyDMtZGOetR0y$DyKG(d-_C z`3+^KCDs>4P5|jdGr&ptbombYXs~9mvV7yak&-Fn z!$p9nHGBw-;C!bY9nav=O8JqS$}=4WJi0&Lcw0sVp;Nx0QOON3{x%{~-ekULXx{V6o_!8uEJ&eqR>QTIOA3A1ZBq1&nTt zQT5*VO~CIe2hypR8%iHRx=YNbFg8(dN78peUEarnjmcV@9f;ISBI)SnjaE5_hA?02 zu9|2(JGq0V`<20G>jZ95Sd?k{enpX$m49bI$Rf<69J~7Wp~6#AI&~P@MONqEJMDvI zh%6}s!DArTGR%d+tJpX=CXAV>i3zB8IAn7yOqX6=(+W0Gli_E#Y1f54M5ZRjp0m;Y z`t-?(MPQfaR4wJ%Xu7;S6sKon?-@z7rj}Z=Qz7qYzkoG1>mC)idr?{~hM2-~?Cm!n zHJdfLmDH{d8*N!nXy!rN2p3LeQw^on1=CCK*ry zpf2lK$h)tL`g=-OKj?_DPrPwZYxao~o>42j7$FTTR;Z1x389(P4b%Se zqB`W3bQJV~Y&#u3;Bn#c<>{Y{eWp*lkZ~0g6PX2o87WLqt=u5HB6Tx5^Qqxb<&$R# z(>LHw7}P|)6`$R_)#atBjjnA=Q>gC3{%6^@Bo0{@mHp>&Eh5z8eGNI<1D=t5OpEF3 z@^xIZH`C|XCTe2-fHWaTjLn!Ie=s$|kPo{{ryj!v&WSXwqLk`3g$F=lb|DGs3)+-) zskmix?$Y2A2S_9CaWHILK=!A;C5CSnt~sQ4N9~Vd7SRfR6^64MPu&HHI(rwym|^)0 znzoEc1IjeG1XmLtbyX+#MB*|ZnwGE>`c9kW*uRRdr!eC4!z(RaZ$aHbyVhPd*K^fY z?q)so47@|b2Q!Z#{2e;<2g93BACWYPz@}Bmz5MY=6Z<0 zkr5g`pEecVdQg^-6RGl-a%0qeZqrNRb&mk3I2gCdPVpeFWG4P$RNQ3G?9OK7Dfwt;__$$i-r z{%DGEvux8e!hx8sjNJHT!B&O}1^q=2^?AD{Z!2gGYXV2nv?6mcv~qT+zxKwt`p2AK zxpGIRj%-xiGMfvzxn3Tu1mMbtrP_%nQ%I8CKanRA-!?wsl^1pAKf`buFAoB8IQHV`35Qs^hMI%{aYg zd&gOWmx1`|q&#S~ak#)Ra>6U>MQS_I-oJ&cov%|>>#L=0?7TIC;^@#;hV{wz1>|VuS0gMN~zJ$Vf)M7d9UtZ+O#~oPUD>4&;-y&3*EaG^~DPp zT5l;H6i5xBsk(OqoanLvFOeg57)o1g4OiS}%VKOapDvQ5kOUPAJsZ?*G#^0;3goen z;s5k!aeCei_SUG?$Dj1tL!072s$WE2a*C*Y*kb<*r}O(SJFM8s?kFa31jiQVq-jTu zA?dtv6%5|TCHozt)Yju7@NFV)N0*IuK0RlBS)BCKm_ZKZA^fJpMmLkiJoDnn;Ck;w z`@lc;1*lq4^yA}i7QQpgYz5Ce9K_PooD2E*vir4mC$}lRGicjBrLZTu-VAH`40~mH z;=7dm^LU-i{1S00BBGlvCN8O0QnErBfMkapfXg z!CAxqs=2WcJ?YTU)N3Dn2HW>Eezt*PV$HNw-LZ8J1$`4Q6C{qcAN2M0ZWJqaH>FR6 z^di{Wo{IXyOI>`Dh~KqUQOzmu%^4dz z14xx#EI%IkbbEEqY2w0FF8OS55~kJq$4p-(vK9VycRk+nSf9+niYMjTXmc)IT3qZL zau7?{j>RVTMFjA^kgaIV)Yr?5)eapC4pA_8|3~AA>^VD6zYUH0qokF3-K=Qi?4Aly zJ0j_k9e<*fbio?C>&fJ$7DuDjO;>%prhTn)q_nx1Fvp~XUw9YEj}4X)jjhDEn?f&E z(TkR8$vvri4^34o%ka#4%rWT0L@0R0=ZVKo%XkGg_ORoeBUDA{jt3tr#*I6N)jnSn zdEb?6dz5X?SOm;J(9wiwj|;FZU}|=n^iJBsnL^Co8)@D~CJ9x0zLLL@m+qIvNq48+ z923!cT1i2-E3Q>HuHWml#VO6~>!o{+)r&i72eA>kLemzZ&S&qguxGXS-kJebWtO%p z&2ms*k7WQzYPm<8*3d^mpj@i`mpx5Dwq~)Eh^xZxvslzmviJ$==l4mmGxQ)E15cl?S2X4 zZ~eDE2Q9SG`AD>Y_|$Ph)^x6#lGgnbaL#?o2UQtHYYMKsLI#uiLB4O%6Ucg)aj(v* z(y3V8m8W86=4|bn{tO#U+0BhVL8o;&kUBzJ26k?^ez5Wo59_MMd1|41D75_xLG{$M z6r~)s{6AKDTZS=l6xs&wk|}08dQt~{Dmj^rZC?xgdy^lQJ#4U?m^{YbTnmJK!JvTu za7^H`?YTErB*gB63VR(o-LcuX3oOoThmX47uuWSMa!)@qQ`8HSs=wl}o+_ueUpO~5 zPF+-v^JER~UC0AR64ZW>uQWXwhN?n4??uXR>!A8d*D)n}+HA$he0F>lrsI}zn1>+4 z=Wp#syUFt$jCp^sL)rboE@ zWp9krkaUdLM4~3$k=}3p0yh}Z&%kaxH}SNl()-rT=_gM36JFzYM`0vySWOhV!m0IU z`>W4g_9EW+(4&`maQgGL&jKaRJzW%lKa+k=;M|H_Jb zsJf<+pYvD#uKZ(cee?11GbXDtRoXQ=1-8iS6D7iOig{$A(7spzB+B- zc?LV}BL17Fs1?hhQ9{hqxZC(Ib}hVil;dKP$Iybj`90NnP|%(ruVvy^J_CLm!S7?h zF8JwOUolTW_<$ZbYnP8{QgrRHs~H^Xh2PNa6D~qs(mk)ITbVTJb9G+GaM|}xSTMtk zToxO~wil&FpXSIOw5x8r$Tph^B?v52*Oa6TLvO5E!L(FbxPHGRD{vvQ;@a44AA_E= zORe|y%&{-QUDs26^67=bq?b8O;yw{Z^qGAyG@~PkoVe<#^=_A;;_3A#I8W{Ui1xm@ z0_^=y!%*v_fZ#V&uSk0QS^@JoOZU(t&kz-sii%lJu@!MVmzt^*(q~Vv6k&QobiSl# zFA6(gQKS=dd^fLBH%UZD()^s$Au~1*2P9ZZcBZ|^sJh};w`5I>k7i7fzxH^9en-#x zZoh7Jb9hB!M$W*iHj@0a$^dzI6SvCyq;AIqa8Xpb9x6Uj+ zw&C`LnSW?Q_E25IBy%$>vng|992zP*XmE9JSM`T+lXiuN8Ko|GUHw6kZ&zfb?ph8x_9hjpL5{??24wH{fmcgYM-upOujeg?Aq6{yQTNl)5*<$JYTpjEUNF1 z+ThY-M59;acb|u$X2nQb_shwcHBGlnW3r-)e6@qJW?n8hU0XQwRnV)K^&YwO7TmO! z6z2HV56sh-k6J3jbT?fcFv3YB4fi!JQD7bLF+`MD6tY z6%lO3RyTMizElSB@rG5OoiAPEADOC(_kE@^3k&doCGc_ z$9+N+tE9%dR7KzXwJ|f7tlh-aQ!6~TKt=07uQI`8@}ws*JE}7UFf++zSrSR9q4{=q zuvY7}PE{qPtv!0<_a~PaBtm-<1eF8zrF~7A&Y^cw%bMpNnLLT^YTO2`leO4;$+}M_ zoLHk`-rafUN(CT2CP)0&po?*sfHro{bLSh=LNC_`T!T1?kebgUgEn()a?<2!MzOt( z`N&IfzLcO-U&dNrzHl&MOibLLn8Gn$?0#iies6)m5s_fgXxh z?_XPgST0%`emRPz<~Q@X6iHIl7&x6ZvC8;UeMfc3OR0v)SM_D(Yh+B$&bJN+-zLm7 z*)UkFH+O&N6(trU>Lh0I_Dk*gt(Yh7`Pjf{DUkpOrPwSE-{&$$HC` z5+72y2*AFTaN$Ke68JDKjjV*f9uY=9VS|mnqSAXpRcq?|?fJKz060G2HIo0QWPcQ} zR;JBglHDF2_T1>3Ui4IDLS)$K1@H#0AZhZ!8@kW9bKlj?PknfQyWPYE8-mVy#RUMq zv~(LtE?MR@fY9%gMgGseX}eD2G3@DE9C1lP55%gz-AZQ81jjbt1Xq;q=?3_}x9cdd zVmfe|xR8X(%Fj#st6lv;>6*37ra6phTlcQ{`5M4VW1W%0SD1xt`wLdyD_ius!f z)3uV@)wEA6Fn7M^!?GV6ae5tj&h{5{njn+kjU)RJ9Y3+Jus`~xeM+9y7_hf+j1v}X zuDGAPyDieEZBk6NI8%_czP3IsU~c9ak8w9Q#;@lNjhhcmyKbrWRy&le^vT@JA~4=5 zWE`_?$bZ4CPt34mZiYplE1sLrSz|Zdt^lh|Ue3Uh{bxPhcEo9YJ&BGePt8;p{_)~Z ziODQN?~QX1OFK==AnueRFKVG-WllBo8@();6;#Mw!CrId=AzH%4Yjpu9dw>Uv+Qts z`&nw^CaRkFe+gkXlx=Whzrc=Z&S(mP&o;GL14q zHSM|paS2JJM(8S9W0u@^TFkuiSgG;yKvBYz%b{7(mv3Jflc{|cHri|D6WCdo(f6SG zn&3rm4dFUbZShCPr>)OXFp=>M0&j?M`-7-`{Y1XY<1QB@>PW zdjtlh23z#hJ2e{h;_hai=N~g(PoL;q$IMqLF4}WvFe+>)I&D+@TC6H&huN%aP4D+u z_`alm>lW90E4dZYFMgfckK|frBAX&^yq=IuZ#!_b_r%ttb)-_qpx5t|$Nmv*M>IIn z8~-KP!z2FA;=1NRiy%kg@?d4L(*fZ;d*)5m99rpd3vmg1DsvA5u z@Efwl`wx<>`N9cL{Cw()VfT#m!2N3)4Ue{-R{azaQF_%`rGWYIi9*Ku#@f!*hmlQ2 zy}g18r{Jf!w4ha7+S#Ov-easIK68&&Byae`Y>L^ml}hIO9)vE7WG&x)Hl^c0$gO2_ z>)Sfqp58i6xVo`0C4uh#2E59ykDV1X@kil#53G=|7N*r{LW*s)rM+K+G#}7SO zs_W0~#UW#WwYks~m;ly(69K2S~Koq($tDU-OQSW&)O*CZ>m=GON<%%TZ31mU7FLB_AtUo4qT%FBEQ3UP+m!-Cbu}{K~7|APRqUlMB(zdWJAh>Xf{@bE5A4=ms`< zy+#zqvK1DH%W=2c2mZ`9FY@m-sUj4!xlFbV!N$+@U9|Az>Z=L+pl-ZAuE{D#=K1AX zY<>WdA+4DjzmQuMGIO=Uf(L6Bs#<5WrNj*wBLSKZS>~g0?~Zi6-W&_voq~>T;&uDH zj!<5BB>uf^NJt*g1~ud=hMkV76WYrB1SK*R13pvG0uIzq={+iBfkY)YsZ$&oTlnPL zl}R|g!~=a`SNhpWx{e#1DNeO-9naA{dI!cLFvdo$j63e#mL;63`KEE44?%-{LOi#F ztg@rU_EPk^r%5L19aEIvfT$bzzK|{4U#&kiu_w}h%S)`<0xO0Bc~0Yz7ax*7L^p_I z;aKf(l>6?mFvo}lt-iiNGu?c0O}+L1)2 zvwy)NOoQ;jRQGLnr)4p5j|wb9oPineTML@gC8@R>qU=1fwX{!bg)!`9*$Jw*Jf;e- z_*sACt#vIj`8LtQ)vP1V7aR-T9+!LjTCC$=B_F-w9lZEuP(bAEX_ptY2GuKDnMQbn z;|KnrCg$6%)byf`VuzNa+L8#bN-9uAKMcuEP>!UfMk5&oAHA}3_{`m~!(r&~ z;kx$|x4S8d&B3!a+~BGuQ=HNOcC#{5b2S&!$7e$ zqTk-+7WCv;lEJK#4xldgKpcMqDEp@G2hU6550W*yU%+0VP&GK#qc`L(8UMUY#;kJ0 z4E{yW7K5U<=et4ms*TF%SL;%9P`T)i2`TXe2mED_L9H9Y>9WDq9z-~uhL8j}dV}oW z{M^(4N_z%n*Er6=@4<#FZ2Cvz+YD~ z^Ww5cyX|zhU2RnZ!gh+wl`a=0rtB`75xZkQ_V~Cs^|%33rbmeBYPxfMem{X1{HTgw$@J*mIm%KTu)%!2}G;di}X=+TzJ3822ntv(W? ziFwnNW`Df#n*Gv%2s~|fh!%KO>bBbYo*G7{KnFUxj05tFbx=-De)_#}bMZp;!J)wf z#p_$X9MHOAr`%CWC{2Y{>kOt6#Mv`-0?hmPEZ-h#h@ry!u3fEe7w+rQ&2iC@q68)Q zNLOH{XyCZut5-*afbr5Dn^F#z zrKGsXFl;D=*F8qhe0^Bp$#)Oty&aloC$+Ptb5f!0l6j*#q)MouP`uVa#}SkaKerE< zzUW_I!n$~hH>B!bg)T%WsrbSg7T z%Y<||?YPN{r@eL!ww)%G(xC*EbF@@rI}#!xvTer6XsH68c3>+gnk6!;^)O#fyr6JVIQ-j3;q7WCS+bqhvX;)23jo8Ur`rQMrx_1IU<@9FotH$$XcaZ68Uayv{ zGdC~3aFyHcRVW_2*mwJO-WL$mnwEv$rDClO?w(zLDb2!VZX|Jh$kju%_hP}Wp$>Xd z)C7-ElExa}-OkV&e6bAQbvjsF;g|VI*28O}32Mgn^lP-3ySzT~BrHyJ6n|VokLsYV zGj6SBH)VhzF@di@5^f@BDA#kGuW7Meqbr|HLEzNMyZoE_1uq1k)P0OF7Ng+w0B7E) z!QvnaM8?3~j%7<;(OHcM@zEdC2EDkFC4+y}#6MA|IKP4|1-7GwF79LxQq=GaS93(FCYeIrKT@(RH=7Hob&>4otm$D-i zj2`ME*}Cyr3?fI!m%-!6gxaK{ zd&o`)VL?-cgD!t|E-i*G0uLR^oWZ$wyaqwC4}N^n#ZY$3@_4{#^Xwe0$fJH9jq^oi=4uD2_og1rHP^&ydLew};% z%K)F;T{5OvP!8()&K&{^f61dtd|}YzQ@00xN=8NWhzq{O3f`;VQH4h`RK7N~g6gvI zWo(M5*^ueb5Fkg1M$|yY3qHiKf*~(Z7;)|2>KAH1v)!7=|vVs zb}Q(x$&LLSGh+z4c>zq0qXW!w!r16n2_9=Fc;Yhz|tjUovcffGHR ze#VFjdNLGISeOG?>>v#F&|k+K@@NOpGWBa6FiO&|F7}ZYkg8{2dfL^8-CW9|T(n$d zT@k2x@%%#HHI8iBG_uTAkhqw}l+q^AR7un6qA9GoK&tI&GmHDCU9Qlg~EOriQ#& z{Lu2{oHgdShYN| zUP1vU(-A+BoA+A#Zq!a+W{8MGEsfwrvUE4v=v0TCY8QPs8ASQtgEg3(*x(ak(#P7+ZwHsoVI0Zxtuwo6H zanI{(WPZpR^5g@w5bHApP{#J>kw~Ix2{_C8e)W$Tk7f)JH{dy7?xQdH1~qK)0&vp& z2pvpaXe@BG8P`@H06HRcFCE3QPJ2dXS!MP57fS67vBj1L=^aLy&vfJS?@^DsoYHoi zO*S7lw`5-1Uux0moPi$7fIBvm@^0LsbumzlHm9B|a{#@UNPT+W)PO-VnQfPiiY)U` zogtuQ&<)d8eQf!JjO9A#lx6PN?Z#TGwL^A}i*NyMl(y_5RCb+|aV>M9aXYN*unW2* zIb4bOCsjuPw}X~S4pxG?RELIe%~NBi15f{Lq<7e%kAO0unB|^>cFg*e>5yGCbN1TE89KQsNFFbuU2q?|M{nbYf7HpnU4~hW<+;AR_oN z18I>LfCFJ|nl%Y;2=Zz?Y-*kEv!rTsBFIGReZn8p+T{cFUW6T5K?BF&=cN6lx0EjV z%rNIx2x#yH<0naZ4opJvSjcy z#u3?Dv{eD$lVV>?O+e0(u}|s z%jKVHHTZ;bP!WB!4iwoFkqlJVN!|``2rNSgseLih%Y!|a>26+=EnvA0%pfn;<9aWc z{caoKf;fN*9x864zFkt8eQ;ArUl?#>(rfZuV2oWpt7JIPzzB2;%;MF#lgfz(y_K>T zZjCq&wbKx@Cd1vV&Rk6R_7BS5g<59WUC;`g1WJUEy7cZ#Aj<`vKfto(f^9N9?^hfD zX0VlD`>C37s8)kp2N(lfUzuaj4kskw;A3^ZtMz=$K$~q9RGA8-llp+3te>VF{RB(` z)aM486Jk-Idh{C8Hi6CrYW;RPuT0*g@DC7~fYbsBg#ZD2pk-jjQS6~|6~hz}^AmZp z9rk>mO?etJ2&Ji1lNY4cAkBzRo+>xbE>I0_Zawn`y0|nYHi3MLRJ}b*{oVp@uLX z={oF5u?&6-8ptDw>rnAWF$pccP3#4=6v8lTVtSthDoMgW%>lar( zjHLM#KrY%f~;AKf2k9D-xiT zCpU69I6aVM9*G!3N`f4L3=2+bx`FzXsnC{G)SD{BF>(*8TV%}vNzz%l4|_XE!LiPA zxtmoUW>=1rU&8DymwBRL0eIIDaQsmT>;sziP$0wXB+$PHj15)@j1A;xPn+CTNKF0- zW0V_)G~~CCdUE$xXodhYjQ zr#Lf^TBEeM3;4C16#-W#pw;X|UXL6BfY;=%CTCzEwi_yTS14kJ7DEHUXM}{I&B*_f zVg&_kK?2m}lO)uXc_K}ESe16unxSvk=j2K1`*Wms=&Wv?(@4$BOP}h9MT*2S znAv7m)H`&fk`5bR?=0|?JvU;m)yGvits?R@x7g0%IomY`bY`|o(|bYIIUxq4Wx_#f zSs~;tZF};(cZq=pCwwYcgw%X#by)@R2g2~hr~h;+@d)HC%df5w9SR}q$(|hxm!?sy z^KEcEM~k5(P#F>i8_kQoqdN@Uz<^)tpTMH=LsI{H*L);``YN1p4@H50me}|4(8-it zU`H}k>&#k!)Wp7ReSKf@j?#t%ZhD^wCy8^1a_m`_NX_6|7n@7`OJ?GUrc8l#`bnB~ z9oT@J8|co3!Ul`T-VtyUjaz2DZD<+zXKVVU&keYxJ<-Q&WW`L`NiAs(p?Gs!(1A?qfwCWKt>#m$h?K<}UN~ z;MQ-<1vURQgQAi%8eMI19PaGMJx0EuYiy%Mt-Hd~9tw86mIjwXlo*RT?CU!lWyDo^ z!KWIO89ir@CY@hS5HNKa5w5(%)M2PBKY3{Ti@FauYW<}8%V~NH*+TJ9p+E|++nx`U z7P7|*ZqCHm_E2zsTM!T%#Pn<0c^2Ma>*~90C?lV2AYC$x=BG*ZTaN431L%`ff^Mgz zD;cSDDSkF;z6%zPj%fIAT+9_(x}uWYV+<*r@z znWCQKknb4%`S-|Trc|Y0#oY)eZI6S;p@kKw%@KsU;B*m&}4O9 zz}Fs76PTGW7CyS(3+yq6Aw#juJ@dAtPt+tOua>2yVoE|CzRVBFPD~kw3hOJLricd= zHc9q7sm8Wjc(q10g|h|s?~3z`jvV9WHb{_2uCVZtmWCmM#q(QQ0 zMz(7q>k6Rv=bvXiOb*HkIM&rQfN|)#>;Y^O&Q>$v6|n0b*yi@VOxY8VDjh<*y`H(S zTg`MXPQ8Dft zVDYyzSH9E@HOl(-kbR{~tX?i~S;>&1?agXq0HKQC4H?=VSJR=#{0;gC8X*ERpLuaokXyoKf0CI zRSZJNkz`d0aQh);cIPWV$hCvYkOm(th9F*1i#r=2g)-*mm?kQO5Zwu?n~K7 z>tePA1{DD9q|!O3fxfQ|LoJ8>153I9S*zj*ElqWi1hv|{=S4~^Ta(ZlF@}#^vs*M2 zc#P8|5aj2iSqD;E^kNUq)X|P9)K6y89QOSgYWM17gjcAD4LI3DI=iC=&jfiJ6m$ar z(5eLR|LYSLo|Fx@AY7!TTY}h4&+h>Oq`cUwnx7uvwveNV#R5kUX@^p~4HQ<6wX z5x5LLNNJ%r+l(~|PqdbVgB)36Xr)5fjOQci(73$~(S1=M=Tnqe3`0QnruCF|_!_jR zJ`^;*5sS+eWN~nyfK&~#t{1dq(3^A}1lXW6z!lMV=zy{5i_zG+{CJXtQ4zKkF3_=dm?w#Z@mvL<^ffdhP}6m^c}M;QnB>P7{Aw>Dc$o@7 zCwd1>1P`KiUF)e#*=5l0blDm91aBe&`MQBre+QlS(V-aK1ff?m z2;o93Xh_oEL2ZOfhoNVn=}A3>*-HlrXbsx?xYWlY1^)&->_RW{y%WtW$D>Wt>bdmZ zEbB8~=qBvvROs+GC0Bwy83W7?@MeO~HJwkb@1Zx3121Ly%o!q~4vY3vpxqNe`emQC%y+{mx1v z%5QUyenF+_b_c`+bk!QxNi3KO+7p4&NA&Fan_9pL)|umd6?9aKKjr*gUB3K2Tbidiy6?H-7#-<^sUlex0i%*apMopUsPC!nEtoUuQsDOvN`8$Z= z-_=m~d3VU@4I@~P-}@UZPNA>>nEn-N8bE6w+u5B&bSwONu}{EUs@(p+n6TUZ=jy-~ z9#x>#AIvNrkf#5pKy(qGzlF3?cY8`-fZjOj1ZO-8n0hsM%T{AEf|I|h=C?b3L1LnK z=}<`OzW_Brn(xgb2D2O%w~ru@4Z!~I7x>+*a;Z2&yQv!he9?s*=jE)mDkh+9tol1p zh@)>QlEeC!L4Wy(`qju(VAahi(tbq;?v1ajkkml|E{thTc_YP(U@~}0ihAL%>i#ax zFDw2Hu)y~g2ZC7IDh-z(S;)OrN*^)Gzjt$jfe^m&ug3ikAZ4Ia1ksJ<-LpTKCH&0} z*T-363IIQ#lSc7UxqpE;HcCD}h=?L!$h$Xxc+o$BHE{iz#BgEg2*$w|=~>jJe1ktT zs(xsGgu9j@>VZh~S-{|6Ozp$(t^(MSG0H~%-NkXw({`IHDw%fY*FeyDQPIY%h< zKbROqh!Jfx#$RNB9V}SGXfXJxvEP;VPXfw_(3BXGZ+p#8J;~o-@sqcH8y8hSd~&() zKYPQkwEs_{()Z<6Cc*Ys|202=@F9PP)%Vr?+rS7=J?frvggfhAazk8r@qB>#jD{^l;IX74P?4<6a8|6@4& z75t+~z=)b1o)X2iU~ew>m|yaM{|0NnF%YVVAMt3^u0MeEe{!pT!=GOmNIK*?}}*0p5Wz z3++(G-4klOp6D0Zp*HsqgShz)ZS`D~C~#R#Qo_Y$vk&Z0Rf~5WS3Jb7d}?*V5X%A0 z1REzk8Jun(R2J878s|lQ#GxiFx6wHgH0QH5lk?>08jRMS25s;9xPCa1p^1qI(LUmQ zocW@(1UjJn(Lqa#QyB>!VGKwutB&1Td~4}u4o;6tp(XRRLuQaIQb=Jr4?gEOXwN(c zMI`rP7}$nDf%RMLG)Li<)Oz!+FQd=FXQw5#TCFjI2H!#Z>2EH3+K)-kHZL6OuoCgK z8B3p#Q5jdiu@EbBf|nO9<46}`^n(^vJ5DOCta)c$niXEu zh4>XGj6aMvwLzzPI+%2!;}~=Fg*3jP(<@4t6=TlaEtm<4tiviZ?Z-e{`pYgh?tbK= z++e});#=h{JM|6C_gT5RN(FB5xfu!n~%eTE^k1l1VFe*0%ht7hFQay&C$V;p|N z{%eYEnZzw$$R;io9+7tuGO(&53 z)I34CSE}>mulXHMW?_-+K(`$6e)O%RTH$M7;gsGxdQq|h&@;$G_;H=q4)I6Z)I+bl z3&2)d-|UHVR6NvNT7CI}(_do^wxqK{!a;q8?U95%dx|R<$3-|v5;%Mj2*JebDN2W9 zD@3#-(|7Nu&SQlb5J8#G$rmf8pS)t55iZdS_A7&IB~OcCZSXb_jUhK7(S{hEfK`XR zz~Ll#v%`5%_yvkGp|_+484qt@!ArrcckD+<%!|;5)iP!!y5Cirfp9wH&+CTZ2Km7(I4H?Ruf?i^(lS1H zq=Q}pf&kcA`FG4}L;lm4|S^ zt`uD|{wf^CCsb-eH-4=7`z2k^3FW8}XaA6HbOES}MPS13gW?-Ug&ZfL^?C_i(LF;s zMndn!OWNEq|Dc%3V3CCX$KHE~HPvmV6bK+Hyp*7zsHlLAPC#D)3t$0JDM3*| zkuJR?fQkhOML`h>3W`co=`|oF^q?q_W~kBxLQP-Z8Q<6M`}VibJ^SA0+~+*^xp)6X zNLI4unxo7)#~8ogV6=1!0)gNFW9Q#v`lr19Z4MxoGZQ(A1m&1dE*hLb*MK|k{^yYX zD`{i=Fp*1qYRJ=L=)v&`htN%F7|0ew3vKDUxRBlTk zHz5qqm4_Me_3{#I3UVd?BTeR~fPZWDZ{7IMny{Ahj4KZdg_&|Woq~j&ace}SkOR^J z-E~*5Uyn{Z(S#mAc5xTGVhaVHWKEo&Vu*=7tT5}D$gUuz>r6L5wx27N-&~Da_q7d` zl{7-IQw-fE81phb)Q_Y5{y}Q^2Q-;rq(iv|eZ?YNE&T3__=|sYKq>3v_jZBu&0Dlv zMQVGCZDk86bj7cVX<5im_`SE|{m$9^I4x)<{~8m8$c5|^>yqC#zCU(#hZ^rwQy@d$ zdoP(9?W_$sF~g>`4LiGkY=&1p36QT3#zq2HwWcvMA~iCSoHg=KljH%;$$y;&eOnjriG zAV|4*7^n^5-`vc$g^H{NxGWuFnKChlVf+cfi@PU&)dTz*4Q2fE|G5MFS2Y<2d^5jv zA0%kkF@UG!vp4^Kbp8YK(?q|W-tVa>BXA;N)_I`b&JVbFKG)B0m!Ljdq554QUFi3f zqzZTt1hCEk=l5S3>Tf@Rt^hee2^6T544caX-goCP9x+B7M8|M7od~ zI7KJ%PnrKBh95YHYXmY${PFVnR{z~>!v1jqr+|x2=a+T^C&d>s-|hw2cz#Onw^098 zg8bD&eU^P4_}kEbI<$WoD3B9!g72aJ#iji=?jim**q>L+gfQo!ga2u*`Rzo1FE#(! zcK@juzvS~jkjL+_VgXWx1z~tM{;6Jk=lW|T$k}%c9WJJsJ0aMRqe2w+~%3&LV@(NKzk5>#D^Zk&F8X4$-=rl7`((-iE#5TmYWz4-^tG7Dm`W`j)LS$uy zM~VidTqxwrtAMm#ONsqV=-CrLOI107PKTZ&f~;x)SRR(&3TO4~T~ z5?*mSWb?jo{Z8R}+&Zq_)3&=$dxn(9)e`m?liT|fylP|`Ag}5z%)2k_kI3~wIkENY zD6R3eA4c>=9i5=jOM;N@402>^vU66Z)SJuejVbbjmtt@FbX~O*6!%GiCddsGv1a+( zhYvLx?YKGHc;v(}bftDPRYU>d?Ld;Y?Q`Kq=gZ3UWJH? zPh6iNy-xe2E%vofMv;DdQ(vn%E(nO>*9>0T5Bb2Gm`{f8TPLi3cIRzBWOWK9XQWba z*(>((Y=B1!-tf@;Hfe~iF`_(0J6a25t$U#z0#~(m;Qe1;pm$Jz zyl-2%xcP*{@#AAwuTGg77hJs}YTKq4k?~kYLTtJnF|@nvs+{bu^*8$-6dWe1JMDYC zL!~}7>U5gng_3|6Gh{ULNi9aq%^lt3YGOaVxGj3HV06O{a^4Y@4bj_J{@?L?D?Ks; z)*YwcY!4!H^E?>IkbqCgiAa_jdDP<=HnG)_+!-^#av z{}tHi|BkO)b6RQfTM|wKSod<|>Fdq3Cu2&?*hvnL#1aTuoglT(Z4Mh-d zIA2XKsY6Q_?pA&GXY9_n8LeF@qvCX2J>&nO=)DRUq4~DNiQ?%QODVY z(hgH7Rbn(1%G?imqBri+mwkElKv31O#)I|YVy zV_!ULA37wJTlzSp<4(0>hbL0i@X5Pq=h@UGh!r%Bc6p+p-$?G-Cm{9@I<5Z-VfRns zt^eIu3S_)E`j*ybM)SRN3a80OvSH)UnMCnQ+x3jwIZJ(KSRJ2n3HsrM-tDF1D!idc zyh+d+s2O4zzg_1<+weL~7eGyW*6BylvYF&8%!rZ1 zleZQ}RM>GoU{7`UyY5~TXBsTxG!GnW0-UEny`$Wx0eb>(8SL^RmSUr6;4MUBI!~WW zPFL~ef+9V%$Au<;Bvln0%l7T!9uB--aWTbcbuH^jJRu)cdaPIda;5a*r=z0@N1C0e z^U?xV{m(`tEMLo}BPax3;!j8bLj{#qE`&}T4Fi`$r@heWhNSIqDM;@%mPI3Kz@Enw zCtfI!@@dYGoRyC(!Hf{HfDa~Z#N{jZhh<^9@jIDXg1!`m#c8TC=_EN{@qxe(iK$ax zTM}x%YoRrE)Hp_xeVA!wb4Ci*`SnDJ-taM+IXUNMv?HYBR8vFF@;DDAdEEWB`Dw|K z;Ju3Jk8PC}yv4cWD)6P{?|7x;;RB>?Cp<38nJJ5#Xm+6w=*4$sDQpwyfp%P5 zw@N5ZC@aFJOUQ}KaTHV|+)3dz3)@Z-(cd1%YEC986SFQDN!l|R=PFAMblEk{zl9>V znkwim&P@fTi)t@Vmwx%O)e-$lES%bjUc?7=3U?=ugqj}hcl++UD|)D@S%@JuEN>#ID^E`xzVt6|V)s8DOLS-{1c4 zS^Iydwjg)DE(?S=O-=*o=Qkv}hdg0`Arl;25Kkh{mv*UM-SyJtk=BVCMe@64IAEVZ zS1KFb&T5ciG0*6sj-7o+trnGbU< zj6)9|G*UJM$sfPJO4tEdk&f49nflTq#CYv)EBl3$z>uJM=?^K?jA^ABP zf4>+=p)PlT1}f|Kt1Dd7&2&}xGNSuh<;p29qH!q!+s00T8g-X$W7K94&M$vLFo`t@ z&hG8~DhZcP9cgAo0Z$wH2E_toqI`2T{DR~HzEXZ<=vpKUxKQ~vTVxEjpBwp21CYHS zP?mO|&x?88-p_W?1BhuQ>&fEds7}rrsGC}?HvMh$9a{&m zLy(Wj-J~2}!ufV{kzbR5B8FUnFS=7yeH3qcefogQ1@dxbFmC+zENNDl zZeg580hbLMj5jl?t$eN^DkPY{NkSk9onKhQHR z8YdslbAdDu^X8^Off*lxFm{MH!Xm&s@F?(OfLv_e3JL^w3V@yXO*;@RQT*~Q5W)r5 z{e<|j^Z)!Q*BVlNxG^6F6;{*fLYjwS&(1F?F9Uon(sX@Ehp}v9SQTdw$9{z%S6-2} ztJY+MPwY)IVBg^SOg0G&$+KF+V=KoMNOj8kOG|(xL2>YDU1Ar&{D97DAbwhH`5Hv< zWeu}HNbC}u2;y6bQ@FiG!vFpD|KglLJA*c0KpA2{tkcIWn8QvWP#01L8egT77@+R! z7NlrL>raMRI1q>r`3cR06+*xN(D?s6{{NyxMpc6r!sxT1E{Muoce?CG;|mOqOkl9o z{9Gu&`Ob3o9e0&jn#yhEXf{W&|MOA1Y6t(hu<4h?@ZCrBa9BgC4kFuCWxmuoPJ?Ga zqI@b?sSdNb)(fwJvGhY}7`ht|Q&V!`%{8C)v7_~%##ug<7f5;GtZ%w`vCpF`^g!S1 zfrt-0_mJ0d6gT4w2>sLYw1B_yQbpR|JT7P-Ct7t=`CHt-pLRNW=x3FoJDl#!VU%xVT9e&;v zUl};$GMaeeUBz9e#`9*GC!3Wdj^d5$9AncjU6Q!!)5` zk_&_9enRe~kJz~hQ{8E%D`SGRHX5dO`^2*3q_ zIBAP`L#)QLyA*xj4&0iKfJa?6iD%#v>Y)OWTd$*Ez5ZljK64qzbo#81p-+>mm3Qpp z=)LxLjCeJkXYj7yUsZl^sku5G?U&w+V$9$7cvy6ZbJ=&r=Y zA0N%2ZGP_)@mh%3y@_!q#zL{)3t08VQbqFv&+=-O8*A%SPaK+G24!GY<%II-_vKw! z!<&oQv(^6Ctf{M$hwA^x$nng~X{!-?M!;r`P^+Bu?Y37Oc!E=}faQFswPP|lm*gi8 z7Z&;1yk9I?X0ulw(u0Q;bKR3SbldzObmmAYW+*d51t?h|XWr($%$M8mG40GqJ)64! z_%2h6=U21bZiL0)>H;&bl4=Nd=ke!1&=Fl4EhuH>h%}y0EK5mBJ%!2^K$RN^l3jR% z{KBs%M(g&}oC>{fz>+`qCnq+69YMR?~r+ zgxW3yIecWXy-5qCVfpC-E~X7rRFqcc(M(D{AJ)xQ!#c!HvlqpEdtmf;bT6Bd9cFo~ z7o+t{=ak0ni9_%OUVaX{WORv_4T|&=nnLE6Sh$Dv03w2^#BI&!t){VdMy44O)fY-} z*Ig0WlFa>zUsirX&6nf>oD{RLk%K*JAWIlr(j|0#)PgTi6an@DG_k=H?+^cShx-S? zrTZrojb$l~6L7Wn|GrnSY2Qz1OI+E=_bMBf9j0iRc!ryH>m-}q0EU3p4~B8C0Hudv zF3aHWd+iQj0+D@nFMlUP1wI? zDWi9`!r#&(+42N`>Z4Yw3#Mp^A7?y@swa_Pic#E~e+xNl2J691GX5nw`TcG#OS@S6 zRZq?ZR;Sl+DR<4d~ zBrhy#UB9RS!TYy}s4Cyqk0ctEjnU{vA(n<2xrg_?ds^3e5heC@nBbaQSY3W@iIv!{ zxI(+Z1`K;jx{Ysj3sA4F8Sv4HCD^G)e~SlL24N1(4N5KyrNN-^tckBsxKVW9A7lmr zHZUy=^MD6%&;ODKuq2?zeDfd{RdNV07#1yr=7JQ^GnB8X_M5aTZUt>=L}>3polhNS{O=NIEVVJy}3ED zGb~Nm3YaWC&c~GW+l%~l+1fP2ztu4DEomE|$A*ca<3*5>tvrR5c_6Lo1enK@HbwREbwYZMsbQYcmDXiBV)9*ZB-+xB& z4T8u5WHUL6(PVm%K?eLBNbBg;xvXyh0*NasO6&(SR>w*)$oSiJ@*U#3zb1Xc8qL)| zUoysw`ekc>i(t|GAz_xbN7Lwc(B+a4`+?dxFEE3-@RTX`n4~nTWR`Ei~yN-HismiMD>*WwCEBA7gndJKJ zq~tSa3f|lOdFfR8!hvTHD0x!=?{J)^-^jAIYB9d+c3@L^r^=KYGr5|DvZ_d>>xR zVNVdYX4-ErwKRER8Aep^sDC@fCOxXSFJYmp1=u}jtUKmNPjkDvX>>EbiKZpLusgPI z%_UX8kByckSPHAV)tZNU!fkxIY%3?3SQTgxYL3t$Y#47*uvG9NI+TqDiF$EgAzEG` zis^SZ`;xQ$5&6hzvPV7I2xS`~vU`hh=}1;5F0C4{rJE~0c$48HKT$%EXo`h5 z;lKl{vT;$M`T8fDV$07bv#=es30Pwl6}*1m_6ZyyuUvamUv#_=q|uaJ-hY)H@8g10 zB6l0Yz3Z6Nww>DAhGkqsV%j#rpU{dvcI+1>!>Iwb;iI`kcrg;oTpLUqzc3%XdHNT` zYehed$OMNwPyqiU;1^pGsDFmZ_qm`B^gLdPXnGQDXiF5u3j7Cr(6^X@qVa8RPZeneq8qoAePS{`8|%Mw5?_LwUiNWG*O zv|A4syA451YtKk_uGU7wIbO@~4bT!|nIVwxnwkB)z<)feij9slfm4yMkg&alo1%9#>^KSSjwEY)ijk8&^OBqJ|^;&T+z^Cy^QvNx_ zjT}Y&V8yTKCLbf&l5*^WTMuq;=y13Vf=22-tF%}k$0qx!W?%{^5!AP8x7l!frS9rUI z%SzKOpXn7@Tc~x2mSzyDfjVwuaAmn9q3cZ%$n+|wyjZcc?F$Vm>C2lt6a(-;DGlnC z3Gp)!s$6qI&v~AFeJRJv)&|a9ppIP#-Z~Pj2h2{)?FL-D?tJ%5`a)LW>YHvJi7u0o zk%bKX@mRf-WJ{t+;qnrkf=ki!lu3DXPRC@b&ySQh5VF+CNnE+koE#F9k_$Eum2Mi7 z-iOzaL8?TinXwR37MPm}=2tCJUgnVUzB5zp=65evEcf#ax=2^Ck$)K85>4LRa#iKv zrPB%-ueX$hd^pQ=i~cx<^=^DeRKL2qTt2G_i-rwCcFgA``YI-iy-m5Yh-?;c`TA1$ zQ%A$WRh*0+=LQ}KyxrN-?N4fj73}UMk9EPQaW~H7JJ!`a1EzyLNoc(RybEC00QzFXUbjM5qRuoJ=KoGJr=$YG(A zXoTgL)1-L!hjY&5)$M^mh}Bi<0B!$y$#UI9Qm&iNoofIepICBm{%%q+j1o70ZKtp1E@iUH*o?gD&fqPX9lF7Tp2BJy(n!7lxyW>XB5$$I zr)r=qIhfaHP?<18%ctY+a`(QlJ8V#M>*3azj`dzKHGqQG9}^%Do)(b5Zlg)j(fi4V z`>eYTcUAv{?sC*czH9H?DtN8(;^>#VC$y zIGKzcRuZOq)Y&{}WKACmYC`2kW^Frt)TYOSW@(t!;3d90R!#QQjZ-JbgU{`Lnj5UP ztNJ>~u3e|y?E1R7)$RJ_Gtm#Nqht`O0%Zry);{_Ql|o|AY~Qkm+c3qJA67eg7oUkZ zh-xnu9gllq@xf{^p+>B<*J^tWqpekc3#rJ{NbBe5F&f(J@;s>I)t0tXF zh1LM>__xTh?|0`nns-*Kv<`1LoY0dm+IP+hw2}_W6ZsK9c8I(Ou{RD!%?J!p?)>5V zBK1J=yF$%3Nvk7g!>Qp-%2_(vNZIz|#h)0HU{{=N*YU<(*P}YQ`pFDzAB5iGYN-0Q zB=NdtT}+#XBvRIpj0vC6X+Id*&BoaSXAS#l-rCCWu%?AQHRSE9<~+jClloT9!fEHx zR0QkvXjdps@QdEAEov(TCBr1kP00JZenJ)C0EJ(f)jmySpKN9x^^}Ez#F3$G|lJ({$69nA?+zfof%dl!7NA3)?3Q3jLD=Os44T^ zreh5L0i~d?)lDUL@ja(c0kpdet=2h9nj^wwcGqZ9^W+!TQr?rJ)bipIU@xoudDm|f zHh$2}F@+)I68xYHolFB2zXnfSacQq%kuY7bGZzU#FLq!!qqtl0gmNyf$)Witw3k(p z-*^D7tXEWzR2?P2Ec9n|=NcWwC%f6C1zy-tu$qBFeh(H9s`!B+>JuZr3oYhb~ z{@ldyUKJbg0RV39#K=jHC!~p6KSDRYOycUYW;*@3Tj)O_Z*|V#>Cr{f5bV!I{&+I& zdeyb41h5Wj814oKH^I;Yc^GT>Mj6_d2{sqOS_460753ad?oHsd%LP6vqv>E77~am& z`T~KMN#MvSazlRT#5%ZTWMK`4vabw%HxCd2gN{Be#^Ltxr2KvXNuDCWT5bHy^rc0o zgrxLsNrf7d3#;C1Q>*>QyFKx9cc*n9HCas%%?c=qp(DR~6|LWB~ zY2Jo*;n9$e&iMX>`)&g>U9Om8F6FT4zD+s?4r#?K7jAx5vblwPKsl{kbrcf^SqB@= zfi1hT^^xU74sG%R=yF`#%^-$e4i^%J@#ALCoa!;X>$!{=|PEX3nzYk9^mnz&#EWeVQNo1K}=XJN-c zm>zhdZtmsH9^Tnx0N;7I29=FfS~*g2;3u@;4E8beh+(GQ&O=`}6IvNg%i38#z#&b5 zqvobMn)eAyVBzOO(EWrgUk4nhvVOSRl!^tr0g+FB1K`kPF*nz>0KUGObH~8*4aN?4 z%g~gD1(qhjQFYrgojo~?OS|I%R*V%ZuC*oHdMtjq1jL=1@Aou^0$Q|_z{3u~KX8m1 z_z|dw(FCq)k&3J9Dg}}sO$U$#uDRW< zxMp>*z_#&K6P$v*0AOAT(5>*q6x;iJadqI7g;#9-#M?)sT7IIk_>Rc8gu56u`4-5? zxIv!KI)@uw^RT!iAKL_4T+firM$o@T?=l*O4F=vfSZ;Z-Xn^>wgmotbD;{7*0?LPA z;m+&)wS0!P^qmWw#x(%<{nxVeYwgUf=7dE%v@_g!tmM%+B0bqi*K{=)$(j%%6(m@$ zVVqsvUZ(g2En#tC&jB9Y9(m)?5)>vQn56rcV|R^evw6Ti}xP!skPfObJ{KIH}&HOdov7CY7$2X7`QZc-!sy zzkE|kRKSQCu+NS-S)%G=+K2cMtdav3CRr%8I?`)7xGN^`CAugtJL7=eF~ z)`T3p{4$`W>vUz!ZM|E5s$&YqgPxWMlEY2*@^xpj^6v&CB&#m;+ z(UFC@h6B~Dn7 zh{!}xD$O}JLeSk$M>ll0G(5&@)T~ACZCiDx39AtvcVLyW!rBwa(fk?bd8ZH62Gx5f zG%{VQ?#+nQXzMh|A`~h_!&3#<*_ivE`NQw(%2lF!gsb>M&5!_Mmoqyic_QM zGsL~cH$#_qq;j?_l(?M=^(%Q1zoLDd{ZL+d!aKXPXQDhe$N^BL5&cHlE04vVJv}8h zD?@w$x-@h~c$;{IQ%l!(t=2n?Q+G&OoP%+Dhu@v{onM#YXL0`gbT*c5fVQ1RMHSsQ zun3KPCs`UHxZ5j@rml*}%E!9t*!fyIw6dwTSKZWKQ?_Yed(ubYCiU9eTGlMxiqJlT z*(sJOrP+nlohN9s#X+cakpZQWQQl_ZTEqR4WI+jEgU zriZ%nP}8$Mlb>SE{kyPC^Z_Vr!&F>L_D&0Fd=KDGrgar-ri9#w(H1Prt2 z9V;EHzn|?FHXlc7%hQeIrL4kJFD=bk#^dVLD=72S{9_2WYT0iN=UMO63Y_A=;y&_B zCBoFcihTUu(KVoBPx(yuH$R7<;`JMc{Bp30$7#XHu2fFiY9tcfG~}(f1Xmx!w$BRR z=c;~A8pvEAdXGwW;PkysEl-7NHuIMd6cf`;QX+R%BK!^NYUZD|md1I@49Y{gbVK(# z7u9{A^}hA?S;4`+4whf`;q7gPs$V!KOO~EmD|(48u6ILra8w{|Pr$xZY!b~tH?TVS z(+a0SpgoY&-iDIx3n!M4fWAfalBHJ6oqIJDXyrC9Yot>enwFOB`nlxrQ`V8acFk=N*W zoMxpM{Y$Y9qBFtbh`v9F*QI=kY9mLFl!P#rxfn|$l=!a}kMd!L-$2-NIo3=lO_GvY z3YaW-ydXbt{l`2*I{Df3GS>ysV<^Q{{tR!OlI*!kuY7;>?B-h_=ABeaHqueKgclc> z6_~_GU@jA70uIOH3dX-p*zI`j6RXKEo{obI7WSs4D>}X4O3lZNE%_x}rAj8NF@`)f z>LH6UWMUVUg%27IYf;pE(u%(D2zgf?z5R1sjZf4`-cW+B<{Hn#ke(ecLEkmA?)y*3 z+3xK^C8dDfGjqZ7AP2^^!x;6 zwe@W=S#9fO)WW_W4Aowfl}1uOWuiNHV%0~w+__K-a~-#_MXA#p1mhRQ@pzGDwsV~4 zo>fpFF<=M;!`to2{Jb>BtIc$>Rjm3x@>b0R&zc(@n3FVlf`hne4{M8CSdgt-?{tBq z^?Ro8>u+XGu_9-0tJTczC1xF8?DXBY4Wn*ZL7$!d_SC506<701;n$GK^oKV2N}v^P ztoEFFUDxQh>hrXZI5Sao=AnGigGK~`L(t$d995|7Eju*Gb2~djZp*VGK15^8yN1za66Odn%`Y>mCac zpkLH{%lt4kvn_eVAX~jY+{YEf>u#<@Qg`k?WZ0MHnlR+``A*JAo-W-k<7Lu$*XLAj zHC=z0ax7_fGB-}4wm6&#cJi3VuZ~%jd|~T(I8RQN$}tQ@KR-8WT^k?XBm`I}JM`e{ znA%A<+-@R01av%;-uNNq9pGULL}?Gn^?eQ|cm&xc%&>5e*3}J(S=jVm^osVhV#xA9 z$}v#kd}LK2Ok5^S{6JOE!!oXJR<0lMLOcLVFXgjS>|sMo;vjpnsKOU_M?o4bR-}`A zX6WL@Oi#b~v{Y{$g8*r;B^ z?66lcYFH75Kscn&{}kaYkGLFTyqO?1!9cYrtG6TM>7|ZkO&EV=KQRcGLYvWaaj#Z) z{V2NK&16(E+WU%ozra#WEe3#1q?yQ90rxGmI-l>kNhuv%SL+y*dMUONWG12-zloR9 zlb&r!ZCW3Hk8bL5e6P=RSC_OvQ$y=Z6K~V&%S+M%J$?DJ$?nFzrH!TzRn0&tL4!`B z2rNmEGg@e|_RG)^ODz!^L_}C1k8=`qCB)YbJ4eXXvU8H|h&Nmmu!}P?@J3Dc89qu! z>38K^`Mh=EV*lAePYZ9$tc7%xN#u}2m9K1o(!-TE6;6H!-+`6ETY6n{qp*m!JYG!C zG6LRSD*mBkDB|_S4?0J03B>-mb%F3;k>!kpWVA?YD^I%W9lV~^G#C?Wrgv|_D`IbB zkQcLaRW|x2yscEFDzzm&J|R;)jb}tLBwDy2V&=soeV$Ra6n%PMu5(Z>0stp<#=l1L zKce^v1lB+v6rm*wGrWsBpN?{%qi^GeHh)ep8qGF)%}hX!MBsJ~t}{ZP>kn(c>zgH# z+je|Nz(C7cD2?aEEmc?rh18CKUKi9bRB6I?;}cOj$P#xw#B{6=3vvvwtYyGEN^OJp z1u8re-L5k_=sy=7hPed=DQ3{s8j4+Hue;l-2o%XgXao)-Vo~kUmn4nkfdQ@Mo)ezw z-l#976KmY1;9aTcHJP^8NXmsKwp?X0(YH@3=W*jF8Tk=ya-Pm)sp&SMcIK5bFpw1Ge@^E0&+N*|s%HNx1=W5eM2e5m+G1;FMCBY;6~7gd z(AugaRvPDNvKo0e#Gk1HGKTEw8>>P}RyBSI60FmPE?shrZ`3gEH8C;_>@0MB>iy$< zU(^*9Xrsy~PcMiq4$3{d^9_Xq-hBJozbodWwY3wcOc(+A#ALpeOWwJ+R^IV;y7(E7 zTVXrzJ~qEa7V;Wvj$7RX0Mq1Gid8!4Yy60)X>f zK?^3P`vk^HKJHP@jQoy3Yq^t>NuV==YP9>vXdWc}-EkFqM-S?eqc)i5wBz$XR>3^sV-0E#K+* zg>m$_!Yf2me$7T@1biMf4wlJS7ep48wdxo=zLbtq`jT7sV%k&2G(N3vhsbyWN-0`O z1PUFo`+}`n4B34gr6Kt+b zeP#Xwnp{Ma`<=gjyu>idD`&tZaD}LC_5)60OBK@(-Tg43M@4J9L0?!dSVr6{L5dzk zh37_aF`7~_HYXA2>!swZM_)mey~5Fn)W1yLn1$$F z{e!_~v=Jlxlve%dKu6en&7~g}dLTvG2Ueu<@8BDxM!Z@sL-sACpMeV}(7hW= z$-OVhoEf7gnChF#S{SE#QO3LRTmFb)PWo$;dHX!((}njhkEB+$wc&cbaZe!~U%62k z+p^C=A~Gn@SOPmE_sN^OSF1n=kRrV(OS%wsF)WjqHQ6Be9wy~8b+^282*33|K?#e0 z{o2t*c!4PR0S^_f{-0vP1zqw~qGsiBMC$x~G}>@};fm3%yC*aRbVj&rry>D>BKCjDFhC3~ zsqGrQa0hDcpFszjs?6i}mbjmW+FpNljSoBswmup?nWO#HW zRtr(15}rsoenZtj7!r>4{a!syo9h~PR7oEGQqee}Y@g#TY}9DfT)Qjbhzf`z`{eVJ z4Ti*Y`A`zU@%~9I0fh4$KpYS|jz8}#3ZliW95 zW$-yCxH30G?+7v$dBPxwkR?6G^IsyR5%+(5iBun&yL0Bebz9!N8ka0{-Pyp*;E<<= z3Gi8Jj~)74Xag)RNki8bcP}YfOKL7gu;g^9p!Z;WDX;&MsAGb_sxkre9>69(3zMXg za(MyWU#IT-tWmNK!Qb45K42+h;6+WWS!z&KDS->Yx#cIMrWam>YIr89*(iDR`D>XE zaV-@l;;S213&r_Ekj{7Fo*s~u$tBv)yy*IYm^0j78@ettJ|PSLBGRi+U1uat2Z7f> zLK0;oG|~_+3<{Vn2z3osN!SKGtryJ?3@>Wx{-SL*PYNFbm3Gt_cQ;@A%J zvddQlzTRFfYm?Y2ndGS1B`Yn!o2|YTYR`6;jl6TNE|91thZ5hLum^I? zcos*brpr|CnkUs%*go3YWos(6x&Hk=uRpzQTDN@Nu@kS|pY!Ic%(je#&mmxWGZPD@ z#nM*K6yoW{54POhbmC3Cc1)5|w3D!Ak(o~K2@hrO^@}coyX02ln8rC#C7&<6BeUP< zIG%VXttqKE(OUlIZLd9*@hbY>yJshIt7-mN1K&yBby--I`INr1h9a-KJ*4s2EqpU0 z-1NRA_sPLm7nFLf4-SS*VV6nEJYPgKeXfCY)6D?>ysLi zD$ufs2fH+N5NgLrg`A_0VkPE0+~AgO!mGk(hu#{ke_w(9esR6Yfti|B55tTXp^&V3 zYC}pGY532|Le2FXuQLhqo36jwVyO~caA0p~Wasv0SAnPM9>mf;Vs2!+Zo60}^59f% zoZ*FqxIAMW)`P(}5n3S~o)WVueqXTcx%_X)=K}#hK`$k8>zPRJ*D@ZJp5kFnF_};6 z)C!km<=q?|1N_P76>cr+e={bu4MZ4|^_|9)<)NB5v!+ymvhxT&dLHd|CQpEUN|=C7P5ySzVW3+u;L!1su1zP z3T3~RrE@6A^6Ak%{JhK=S=Kd{ZmJJBuZ9W(Ac9`$Dw0KBA-su$u zeW-a!0X1!XqpM(&fz3a57A5^L|3WeK{1qjWE0pWL6lKErbZn6b%Wb+ZY5G!9vK#wG zDf;B3!#4W6f@nL|mBT0N98>YSV@-`n1FzK4(R&~F!ww}yB-1Z?K>{!Jj|?{4JGiP; zPh1aPVzkY4Wj9y&x8iy@Ys3Y)F&0jS3Jtig=A!BaD=3QgIY`N~`Z- z1H8In`5mfHa^IC;W-F5Nh;8UuPb1$V}=R zZu?2A3-?kTA0h!`h;@gzMvr4_czKGebv0!XfI?>QJ)c9cLvjzC?r3RD9dwREau-jT z7}tK7|9s=+0NZv9wq^Ht^wxyTkL>@X?yi#XF{9C2lkEN&N$YeR>3x2bQ?X0WgQ173 zn<-U1<}YQsX*B>Dnud`^&scu1!ci<+jdpzc2fNG~t*>7Vyh-ehOmLjaH9~(uaV0`W z;BQ5&+lP#FqZR5e;ZfJc56~^!jFny?m!5E(LNvSeJUn#FhYN4a7^rSOB&t0 z&p4)E^GABA%3IXW#|4ltT9}Q42G;>hr+GqMxoTAJr>Uua!w%twev8$kO+SQnC5g`l z69g7;jh5?r0tmKg-`g^j&CIq*Tw0grmpEZ*Sl5paSi*)Hcw_i&2$0WQvgIh(-xK2= z_6JPX3?@cL!wT$hBXl}RWO*0bh|}c8 z7Wa`9*{q8-Ha-rIsbfYBo15ai8v9?j81ix?dyc;I*tui~(U_#v$>$k1$?Zmxa&|Ynk==oCofx!ZI z6+!A9cyq|k*lxeBhMOLRGpphv)*nrkKJ2L7vAS08ZsXu;FpP7Hx+MSc~0HafVLUwOy}RFiHvhBmfoTkCDtpi zIh)kD8gdpDs1+I)Efq_|PU312-`$Q4tKZ!B6Iyq~;GQin)%?SQQi>0GaN$QPDM@O&Sh%`F^kY zBe{3iuvlr+&=b^C&Le?v+0}QlKR<+6tt*-Hx1Z((diJ%iyYnnzzle&DykLvrCAY^` zU(?}*gT}l2{=8shXv%cWJwFjNs^x!_AayjlXNeJC8@!AQilO;d%XXo<5ejTqMF zF>`?N>h}KRw&4segw-E{Jwh1`(jlvj`9;Ou$pU5Evs|jrKRi7gbwK))j11&+#~{Qm zew$Ee3KsRPC^&xjlrTEUOjpHe-x$p0xUVQ>1>;Uo4m}T> zR}8>2w_x{e1@iAzn((+LpcJk>H1cfoC6xFXYluw);ib>(+}__)9yu4CohbiZ`Z|yE zEcB-hp(@-z@*#vlpVGa4pIO<}qdBr<(CsH^ZX~xYXV?}fw8@a7#*sN;NS)`vd@9Gl zGK(mLxeu&#-3hX)6_w@j)AG&wqS4qL0?I}CZ}@gug^#)aqBo2#<{`$?GI||ov~c(s zT#LG@7ux^2^4Fu4EZ_M=WMd$ikS5ak6KQY$^jH1C>AQH*|7AQIWLEwcBj5jxJt3+F z3Q;G%NRiWSwQH-hJ=j5mAH;MmzwA3{X&`#^(NgJ1N^uUP4{o%Y%R3hXPY+IQBkM%Q z;fNBtOoA>+J&O~J^+4LkuI2nH$tfY7Bdo1$#mMx5(vKxks7wC?Oa1>~ptH}K=|j;a+g*g~xyRRN8AvRtos@sdDAyjEAd z+BjJba|!5^+(Q^vIW>5>E$o1yCM)t!kmUr(g7Ji;TusJ%>1El03X*35lX0Z**CLvG z>Zt-gg*!gHTJ-&p9~R!{Dxy!u=alX+mgZz%+0iL*e9O|aD!!t4E;|X7og)B(bw4Y2 z^YPfIQ4eGEI}9ym^baO|tsxQ*SC+kQJQ&1&86(No3h&{CS0y2XH8BrI$F2k=?=tE3 z9SmFH80xD{FTLAO>sK;IofT_kU6wQ#V;-ss$pDFdD!9vQMiU_NoV2nlh|tlw$|>5*rZf8wWwgG~ zcq(+of-o5OmVIUnrBi~^rtXryX+Z6nj@eo#$HJOHt^-x=6io6iGJ&=}9>#%(_dqX- zl>Ew(o)*jo>lzqfg~^{lC9ww#T9|<-a`bQ)dXnk&02xOCA@L<+PdH5PRmLoeyRd8l z+YOdP+=zV<_x&RT`Hw@89+UumP(?!@jSze+Mp~oBz7VGqunM+1o!tj>6Sm z;l|sgz-lv?(_lD^rn>`>p;#LGF<>u1$%lW|R6iChr~r`n(MueV;C;PJ>MKkfMCyZU zARB)xEqmwP{=t)9!RbzSGg~ll$#&964i?7%NikRTCG984dcNXJZu}Q+7CSr;2z~@3 z2&!rY>H5aj(-4{d`jXFlH&XM=M?q57?`fZ)})>j@|}D4)vJ+;DD1ytB&8Fi1i(0mq#^6AOxmKUWg<=!w4G z@S0asGf0!=Pt_OuOtF207n{x;Hq?|&%3%9bSZ&$6u8msw5p(^>nqqQs7RA8PTR15& z5H-cwZ@P`tl3s#HdM*Q+6l zPM-F9*1&Y&)Ne&{JGQhcfGssX7G&#t6OVAf$U?(oX`!m~>K{9G_9XO02w)^^yyI7 zNYHZVPkFJ!fM=j?H&h>yBliKUVv>{sF|Ds6cvnOo-x*FRf3ICxal09_hpf%($EUNk zO44!Z;myP>Zf$7{T{<3hy{ErP&ZoLcl#Dq)h=657J4sb;to_jOKY&OWA2~n_=zfBL@nWYRF7!K*Q{Hz~uzj#F%`lbg-3V_&dHDkiSzA~vkEmY{@)^J8T zoBwg$bG43Yp#&0xMV6zTh4jpGl=}i`{OYmQ(x{?uJV-~{&TKxVI`@2%M6aWJy&SJzAvyS6;(Pj0*!J-aLH9^mQW33LOR8kiAfS5C!sBZ>1 zYXm^vO4f$%X%6;^YLEqy;BMJaw6H@`QW&G<44C^+Cc~x(9{`)wn_0KtoW5DsV!9^o z`*I{4Bp$kUGfh?Ow1ma2!cS|h{&U(1aUCex)aHY#x$UPjp9J2O(+OrgaeSM@q>D1QPA*@6vt`?Md?Rn_<6pfeGmhV8-A z5mxY!bh*0IDzP{Gbomrut@V=GP9qM@1wU?yfSBCXHn!d?NXsp=+3ub5by5TZYvnmW zA%N!N3Jb~r!{O7+L__ELZn5X^F%2g}p^Z3%)@i*5{0UuFl-2pb&v*xP3FUlBc7fFZJ|Za_Y&PkM4@DqJDn69$MUHW+AHe`jfTlmT>jhanH&> z0W-7vKiBV594#mu2x-&Qw6f+?!VF)+Zgp8fLwJk1)gZzE;O}rP!3i)jw~M zO5q#2{1PY3lf1e+IBAaL(M61%JGo}WM1Ykqw!>gNCTyb>G@LZ2p0Ay##hZb|$%L`c z!ACh)-!Bz&!tM>d+4-X^&W4*_e5`y8^llnvRYt=~f6%GF(k*`N)i_xv7%C=qEhZ*Q zaM!Y*D6o@=~o+~u1HHyd*e>(q;;xppg>gPWf zM*h?^?vm1~qc^%`>Nyk|Ky{rV({v9!j{9thM^X@2Jlw*185$ix?39nLKDA4tFxjEr zP0Lw)Ee6Sy9In1WoGb_)e&`h`?IOVO#89<+V{n9rljkN zkU0iV7kd|Vk55|EAxd^8ER$$%2x_5-OTxu>n3!kSdEcN@ylUK$So6XQp`pnquH_$- zOq^d>8y%G&3cKg4CIWMRkd=wZ+ye22FvknDmt`O9rjI13OCdJt5;a>_pkuHy(l;u< zN7|ZciEinu@1Nx$eKF$Vz9?*Hd9BZ3-|B2>slv|@dpGS*>0Fq9+weo(Gg?kx=$6ad zyH=XV%twg^ZB2j_56f&*eVJmh^7{F}!uMArB|S$k$2&qbBtC2MJXF4(WddwzPpJQ* zEZ5riDs+MP+lb3e2YqgA>@EAGA7lmpr8nWpXwv`YD=nozPx_qNWG8oCC+)#k{pzL{ zGmuq`M=AeR`_DfjGymYxc0C0#Z6TFgGsWBiuPh;UJ`BI zO=(;?LrFpjm98N{&qTm^^C{n&%lm@}hocF0pxgsei zq;@$Q0?F6iW}X{rBHzdY4|gxQ+5imssk~o>%d_7u_0t88&-U#0Ii#|$_2@*>kISm# zQpvAWEE_-F6}3FUz2f%0VP6%VUP!qoDmt0aZ!GR4486>BLxYJ_-W$8a>6X0eO#aAP zG5aChRs3w*0&x6Y@O+@lD4nfFXE=1d9;mOtTM+E)io$O#Je9fLqB=X1nK3o1 zxu7+^_H6>3x6s-1^fkxQyqS%?ts4Z&_51#MARO9KH;98|dqq(q^(C$JB`}W$-!$pv z~PhHE*?5iog}=@VR1C$_*jcKdc{H<+9o9I(XyA6w$EY%nDIuWdP5zbyn= z4@h1GzYPr*AT8*O6mpwC00dlJ3kT&6!vLKeux=m%4RVKe==}+7wEyM@{-@ivdmfPRLZg0nUMzYE9fG@hgjyX3;cb_W`A`F zG)}_@bd+14%xPrr_|cj#N2$pq<$Xwtkf#R zAC}J=dx2H@f{f>Nqr=jB!7-RfWTF1Jt7`(Y@9Rq=S|ozRAP3Kcp}Xb*bOY#a<&SP4 zLw5ge40Q&!5jRW1?*&ik_AFTZcaqc&$I+e67QNhf28)DE7z-$#xz-o(EG2yR8_#LWo%cFV zwn@ht;27)sA@fz(!yMV0&7ZzaN*G+#2X;S;27c)|KL@H1x-2u;sE%n#)HZGAE3 zlNRJt+YCkT&=LpJeOw*B?aQ`S-S}MKkz9cbbB%7kyE|=6!PQo2W$xq-) z$#Ed;W6u+668Q4S=Q*a3A!erc7@dJEhLy8oazHYQ-8BL0g|r2 zd4TkxZ+w5VZN=qC8GyiBQHT zSk1Sr4__*WDK&H8o0fMran@GpS#iB5&2c$QN_XMQ)`QwI^wl1ag`R+~(z3L3XTpcw zb6Zx1kd16u3G+=~$Nvevgnt-rT-#YwZX;9S!^yOk8}KmeK-;UqM67q}T0+1~r#wTq zA1qw%PO^(-Ouan*2jn`pOqE9CFu;Q7`ga@5z8g>X4K5E0Wphl37PZ&HDqYWYEQY)1 zb##K3Z(e@yy)-q9NZP91`z(`}pRLSCYfasJETImXqeO9n?)^0hQkX0`;ReQnwGm~k7WCmkd_*%Ymfrxk})O%>{*ro(h`(uwGd;K*}qC3&wpV92)U4SA6tX zb@%NX`9M~-FF}mFv=ZZ{vzcsA+0}_iB{_^Dz>-3>w=eQd&WX@TUa3FV@Ok6D4vL9P z(=UgXmHk*z8Phbr4NbWP|FK%-=V{NUt8k^cW9e{q?5&ILR>{+J@8wl0XNyH=U$)7u$^Q(iJ{j5x{H9LLMzKzQ6Eme|Vq zMl*@^Ob@u|1qB!5IDj@*-MXX3Nuc#{_HM*6b~fTbVF@r=wklZyBXg8XfyYIA}es=jyRvUnNM5L`q8 z3J{%-2W0nk84qTlnMc4$NQx{5s?sc)jx35PbVY7v2q9O$17U8_4SP1zn+I~2T86D-_@|G^V6R2R zzHVOwBNJ~*B9vDQAKe-mYH04Z^9Uk;T7%MbRrJL#gt`7IaWK0WxXf8s8h#Yj=UM-d5Ov2MVeRxfu6@72FU7Io|G^!oLbA~ec;2T?H*>E(G?NaBfcHSi zQph&&ev0Qv8*SMK$N2Q3CRyTIXAL*&2HC!#O81_ll1w1 zANTj8Ii$R8V|h;(T?)l{-^0cDYr}u+p1*qOKmGv!y2g#A9PBW#J2z~9F&xDT=2_5bt7Dw7UFaUN|ynWSvc#w39PHadR--uP{I=HCz2--hfz zFS%2^-x!`*6$Bk0$9a8;+{NMRS?$aOx?U!-9iS;%<=iMW^_-_gMI|*o2om=RZJN`R zE>*N&B$K^Cl(GGyi=X+1Y3+R`=)FMg>3V+sZR)-88ggskdk)4*vtqTCXqbt&c&W!A zp;c%PxEuqiJ9x4nxror7_2MkFkhE` zxc|iA&eCpY>toKZa=O~PD+fAD>))B2T^}YcIv0pH;6D7?7-KH3vVwXx<6481iIjt? ze;7Q=e4Z>F_Q_lF6nO1Y@o1Xma}S47&xWO~ zfH_ve9OC4sUWK#ncVbSjG>RR*k=)vuv67H1opP8-KBdDtyU9fqA9I6oH=cl%)zk2)k{1?cYYScZw%tpeWF)PjUSurX523CsVXEx8 z%T)MamS1?HiOgFv_tLTfcj1mM5L7{|Ww;aRRKHWUpamYjRzqzlnk=k-$WBHNqun*c zC>g$BAP2_wjwrB5T3RFovTeue57}^P@3+fVieRqf&(D5@U0x*+X;c%l4n_LR&(K8% zb@NzpOEEZ|Bd?7eNivl@Xr;~1;P=}fb6~!D*;rd&c7>#Ka(TFrp38JECs~z_ud}qg zircovm%)*w?HRE>d4p+QGSk$2PM^EZYM|>kR zz`lJ5_eoU+o&K4grb470SbNEVtXMqsJWDxwJh4r|=MNXNxtA3O_ywmGu})T3#F~YrEc^QMsoE7N8MbIvxT(6Z zuJnuBYY%jCuc%|X_>5mOdxWac9Fx!v7XLKx5b6{?b1$U6lbGNQK7kW=#yZq9H?oEF zB~L8tw_L<}1<8@|I+Eb@c?j@bF^XJ4RXvGfo!mrbS$ILRccy}fyB!U7Ugr9z>QOx8 zs6axz&@dzKqCuO@ceAgD~?V>tZOGy38*^5k z*pj(5`DlI_`8Mf{l~^C&geDmo-=!gPRMzVVdFFx^_);rTuw&HGpy#j#tZmMS(YTwg3|JJMSEC^Q3Z0>%Q$w~NiLKuh@s&E*+gU{7Z_$o>*0aaAH+@v0Z3QQY& zkwf7e95~)rR4g*7?LHkNy36>x3M*Xya42js5#SCI=bkQ&aukeEpR|zZbPjXcQmKyT zx)$_vt5H-)1!(8rRc1hodWbLt*t=k$vH6W1ZXPRtvnW`F5hRQ7;I6lu5d{hrV#ceF zo^?7e(0i((ii(*+){vfapKe{3SyoFIf9C7!J&VmM5O02+u%}9$|D*1!3@>Mv?v46& zFiV8f*I?VNA6tH!aQC3vym$;4S&yuBAFZZZUElGhT^10Xd3BA0Erg`zA-+l;9+17Z zV_Ni#$sR$OiPRnEU)}lWTOTRMb8^>iuSV$M71MR4Wzs_(STJ&(Xqa-szR!6_=1q(= zf7`?>&+(5Mfztb83K=j8lTex2$Vy%Dj5*|08R!NXiEr!JIqoEN9>-vDbHDlfWV)Lf z()Bf#Sv z|Hc?QRXh<8rE5UN#l@$^{WQgWEhnk{8&#!b@(l88S2P{_ZS;?OB7ra4oOVL^y3MuN zVa}RuE$%dR*SvHRh9#lp5nRAk)Bvq}wqBz?Ci7N2b`Ynl*-EHCcd#leOfJXUj<`fv z;u>)jDkTpKAJRqA!Gh(8Zos>l`rJ&B(X{Eu+BriI;kE`bP3sWk^dJxNBJr*yuC* zl1Gj%CiEe9?`0eUNIezkMa^A+sy_+O;z)eS=JiNB@9y`@+Yt=kI1;vrU&T^HNFFuM z`Ny>j&~K%Yr+}@buTte%nMIpg_V-QRuJRmiRD5XuwUM7bKPap@6@?Ott8>d15~8SE zpX~T9uY`eug=%1VmiB}NDydlzbLN7}Gv!}ky|zqLlNfn$k0||`zSNEOW%SpBl2SGP z!WRQ~6ve8e-m4Z$`el=W_>a4*cD9BAEaplyS}?tM)V9$eR#RNbi+hg-Ph^WW+TnG! zpzU^;?f4`%6Hk~hG`%4cf#8+gN5t3ST+go6#wI>{WV&Lr3QkEL}iD~e0 z%d0j>0zHo*iS{2C+GFO=zt8A2&5-X(*a;npW{Di>$PJ&6DemV7KDL}5RxC!a)hHyq zv%39FMld{x0`|mvmfFf?TVzViO3uY(Yxvt&<9n06-n6wEE*KwZvaC9EZ8pe@$vMPe z24MUoRfq3$?BG2Y+PK?N85vf`y6YmY*# z%b&o(P5Q!1XDbV9ro)zZsXbQ4ZUPq_2){a{Xw}rnFp!>-ktG9wag(M*7?+3mt^A8} z@)7Hl+>IbaNt4zB+VS+p+8TbPXQ9r29KF{)zGjqqmCltSpKQ0hD}H=Iy>DE{+C zGInB#3J;`F`tRS*J4j%1D%bYvz#=+|jwbPIFf2;6oWcBq!q3G!%ny{(&cKbaQSLNd z&@rPh2Om-l>njhxf7}nhY240SW0m(7oU!bkZ+I58MVdW}A9~#IGSu(!)H5Qt&dGIp zOZXb)x%vBRw)Apvo;9yW=+7LebtE82XFh=sSm2wRkCfYL{cDdzt0c9G`|#mJ#a(Q4 z-SHmQl|-gPDkE{>1kqu=oP$|#yFVt`pSGOSxHP0wkVJT^Bk+Y1MW@=BzZb3q&`kECe@UyLDrTvRMkF(5n57YIKdh!35~x zn>(7VsT*bN9`iFO<7x7yfh=G$l{Q)WEKdXKkRI?x)yWd@Rt^-`1I6A@}~|fcf+vZjq-2`g=t^teOs&D9fNB~ zn|JlkU4HVg&eYH0#D#)je!#ffgc~KKpwAtX)TFaVx%v9HA$9Lfj|NUXTVyij$a%NC ztinR7D`qkvjr!GlkL@2`xrPbTp-=@?F;$@sRZSg!WvEQvgZw)mtKKCSr5b{tf#J~z z>b$?klU2AcAu^a zW|ZeDn%GNOYfz3?hJnt1Y!$i2Rgr^ho#oP2nB%|oZ48C)K>Gd7x~9*wCj+rRJ- z#`zTR22+#7bZhvN+GNVCq5B~;ui}#XX|IY>F+%b(`x!5URhqV1<4PqosrvgZJvQU_ zhjIzNmXp9j;#M~D$u^jh`mFGfE zXrefN-!D5B`;_ZBiLF4EQZGq0=b+*XRRLQ~uCwJ_#z4gACWVgEXdQTK^}?nme4*GI z_o2UI-ER@hAf=X$bo$-&@NIT=s~!e4zd553(9*DE2Z5o=4Mil)8}Iq+{&MC1(BcoJ zWlt0rsXr3Uj|LZ8&g#VS z>G%^OTP2fUUaE^jenV0^9jm9B?^p>8TN7xjY@r;M{FdHkq4i74pYM%3q8=-uG=Onu zY2oaQHJRZr4yVw%HDjZCb{z9X?3o{-$&g9v7=~_zvNqfS)I; zzByvr$uFEv?Fw$WWOA-tx5o0?BbO?V70PCoOg8ayO{_Rt#WAxvUrss^DyHMCDIRZl zSj@62)y9q)L?5y8bVb5?NzWF#_CAxb92$?!Ud>oGAom5goOx&#@EdH+4y?XL;aF41 zY~97df8*_HmKg~PBFEMt-n5$MqI3#S+Kzn4Qc+#pgAL88HJmsIEUpt;vZ&b$eq+$9 zU4XBR;GG&_!H>izec~XO*PNfeUdm?Z*7^Gmf}iZTX5NYN954du;+yj@w<8w^P+C}F zL|;v8elG6zgV}R$#EdUo=lLW9JH_3Tu%tE34+sf@rJZo+Z_K0xHH@%q><`u%q1R=% zGv9knPbUgmIQZy8u_3|hZ7WqzS3WPOi@ogRt{=?Ew_XU5lbz9*9*^0>2h2OJlU804 zcFWeh+A_GKKd$1OqWJh5Icilz3~&}}+fjUNeRM^4rovZ|;uSY5P-()i2~8$L6Bc~O zP%0IUMCTUfg~yY9$7e=p&EuMkNLkuW&Ly9nn!wkUjYaq*=q%2!-WVONl`Uh0@L%WA z>_F84e*P%spz+%WxlA5FTbM6@r9GY|_?5Z6=@bs5A-{n&A}9Y|HL)Z8CXZjCWV2K! zi*r-=BRBR_U)`s~i^m6UIYmXWT3~Op1#08=l5RC0OI0) zE6TE4)8S-kFvdkr17?dD)ldmG8>s~HK%Rw+NYQ23O&la{bsPv4dVwWmRku+#k+-Hm z^=Ty#A6_~s=mRw%WsPNTfiyC61gQ|;SRo0pI7sC4&b*hzY%qd{?oA`%9cz1p-qtBj z+1bhOc-FynbddYhTGMzegtMVZKVE5RKadqn#FbiX`e<0}4SBx1>-w#e9fbg&jEq3m z7cnsq02lQ|38Hqv6{(~W*TS@$I3fmn@iOPU%}=!!VVi_j^fwTRsq#sXB`_@6`w4}`^i zls!(9#nF)lE(I<g2PsM*L(%5z)+naYk1 zPZZ~xq!uW{61o8R;cQkwe?>9h+WYu|vv>4FqZ=gvQPBsPL;S|FZf^lep-F+ep7%N7 zUrZEbQe-pG%^b&6uZsZOU|Ft}dGLvJxaajr^*HEbsI=3#kFj*Tg+%CR6-t-86($mJ zqqvke;Y=xdUuowNha2L{MG#hfHAP=az@sMWept-LWfN6x@uVz}l;T#F23dC*P?`I` zVKalKGo*ckz@7jpTE6@ySef8Bl4)Q(ApSnS;27xoJMU3eX=~)k=ZD@(qOIaA<G1Y z0v=jkNd>Z)uc`$G+AA?$(9K9Gv~)s@uS1Yt#)82|Ou-OnV3uI4ZTiT~7dyc0GfaE2 zD$}CWOUHlWq|3uw=U=a97Vr_jMDb`gM@Fm4Uf{2mZZX&uVLUv(5@{IW(V><{C3mgkjkVkSO1?^SvgRQPUEvHF;Z`YDhsp^O->{zU4;%qOc)DZJ&u z0~~Kc?f|Qj=Aw0GU;|@Eok65HA`@mCR0mi`O6ALLyin$rKa?%aUpBJYe)pRv<5TtF z*C?Bk$69NchSW+Tv7>zhL$1%N$g$M(UUAGyt6vO%gWy_$Jz>d*bRGH%d9W(xv=GK+ z(Dbr;M3j3~_`}UeyXK$yH)W}Lc_a@HudRS3s~hGX#UQbJVbJjKP*ng&Wrg&lPM;d% zR1?u;($S)lNmMp%n1vn9=i%c=_02t{A6 zUpHQq%FS3I5Z_z3klTw3xs(Rd>{S?vW|fK9sp8ibaoK# zcqDAzr4jDDw|kfkb$>8n=No|v7CB{bwIXrwPH3K4{|nm&Y@8>HNU3a<48R9hQSDQ* zUIxZY1I_v^PZK`b5~f$9e*j)=w^fQkLg z{TXS6cZ4g)Na3rPz|HyxHq?WLb)&tc_Ii%BTj=``;@2=8`TZVGTLgQaek94>%hNXS zeD5-&deCXt6IH?cufy=c2spLS+x$hRD?Cir`BdOBvbQ2DYdB!OoTfn%r8pcQcpZ2b zD^zzQLAYc@Sp8I$y^)wq_}(UnMY~@SiRE*z2_76fEB&%K9^lFS)-e%<+JE}|>FcLHxheP;8di{hq~krp#)jvZqjMyj z$*WT)W9y3^qr*5OCbnZ>q!*L{HPf?BJbO{$M)@fKXo5%t-?k z@4|S%(ta@=SC923Ib5QWOH-2;DY@xd*zb-4BwL-+2`FnTuA_6$jRQ1=PmHA-U*Yk1 z1le6>AM4X(^`fEXT#Zdoa4!)~{dx1Km6*c<)5F7KrlgjNtQnS4*nDBztH?SpK~HOu zv4#Z)@rpW&DTJZ)oi1ffJ7Io0y_(v6X>mShKHLLS>M=_1S-Y2;(%?8Sh8-YTZkkSI zJ$BCe0z~$qj}1|@^uz~+hG&@hf2jEsyYb5i$zOSfYIT5^C@L5d7S|8;nv;^ zt~0Gy<9AA(*tOs33UqcNFmbcZw~VX+S2m$9a{R|~ZDxYT8p9EBZWENuVNFkk!$?BqPxXR|Ii zt$hpF;1;J(WjD#LKD?Ip`6|{6U1&E1bBH-&>5Xuy@&1s!{&}fLc}`1NmeCXA24;Dp zI^3#+`r<>nj%i}by=QW3N44kf&B)&)B61s&^5(4li(Vdu{on(*ALB05yEVtx$72QD zo(9VCCNI@TEY=b^LIwEURt4?m>ortw?P5o`BJzwSJpfvNGsXBTul2Oj?%HDp=M%%C zXed7m@qJBD$As^>YnZ}ec8$IK+QQJip5NsQ3*E5+Tkg4ObKa!;4u`7oW5Z_z zCkvkp*JYfzjfpv_d9pgILtnwn*Ap(5uQMPE&r%$WoXac_oc4=W2EH`YbwFppx?%tip1kF zHpI8{XWGOKuP5>v$dwox;AExJ7yJ`V-F)Yse0>OtkmHzh^&-u_+a;=p#c?lS{X&6q zbSZKceC}@&XmMcay*~?d>R^|zKtCBio|JZ5lA^>gS7Tu^87|!Mb>+wJ_>F8K^s+Wt zS|D&@{mcSV2~=iM8cg3Jb+WdFk1WbC`7BUT2RN)Ifm;QW{|A3v4hJ>y!0OP!bOPfF zWc@gua~*&`t4AgSQg^(;lFonn559FZUEECE>nKZ_}2$(1Qh=O#h#JGWO0hmCq z-&}%C#r2}(+r4FYLgkiO@G}g=QIr;Af04e_5`=+7dw&2x^eX6F zpg*?aVPrZ8*AWi6FzKrIBzCmnN#8mshDMMIMWD%_z!|Xq9#=hJo_GuLNY$AgZ`?Yw zXr>*oN@1Sm@#@`|s+dHHM}Clo$VTOSVwnL-=}H^KRS<=ey0ysYfy}mr)k?LXWZHGj z@BLF_^DlEe7YNd zjMjKjLb6+&J0P?iEDmNo2XvCC;4+z~3|W{!U=OrnDgn*0NrPv{lUsGpOkd&Av%rG5 zf`M%AwhcBC9F|Z?&>h*9_1f32_b2c=oBMz750m4}Iswouu(SWRL|{SWKL`!_eW8CX z(WSFsdGB9$^0)i>+ft6gh+76P>VMMCzi;#3uOo66w!7tE`vz_j_HjRu^}E~t)_MP* zmKKCnsR?~(BA8`W`L~}WI;(XPnZfn}8jSwdvplrFu0Tj`0lAm0|37r;4jLH3bOc`# zxxX6v-NF^fG~1|edzAk%zW=_1{^fPQufa+VIr)+eeUKrPVe)BYHTYhI@)mP_wl8;z zc+X{`p>4+lvKwjeO!KSDJPa+sbh-9(UJ$Tm<~QrDYA_p)Ke3d%5tPCb;*h?m*tj6d zB@3o=C9@Y{bOCVy%%1zE+PJp6u_Sh{q44*wTGD`ji`p?*ioSnTN!rhbK^K{h6^+Cpplfco|7{#R+% zjC15bwq5xBN>iDy9H3%F;Aix3khIs1=O3?2C&dS=jT~LcZ2km=%BB}TOFl7XM>4}Z zAj2zbbz8Mc_7d%DNR=CFYUe-<`pnLVtSKp3-#uPl%dvF{3NMx~k zWl-3AxGSK(a)9baGA|gbFR{_7RaD0#&@-zGr}HM*sx71+2h$_?9=m?K*JS zMBz)@T?u3BjLhO8+hOgXHF#aa1s5W*aDl!$IzKQ#{V*gFm+hVwe|O7yhkdoE989x~ z{}QS`C@NYmsYm3LRX!AhK)=AZJ8T8~96WX${f%Ilf`zl_^P>`7=x_C^zfhQJ#*KHp zh6t`IWr>^Rs}hCLjaQmQxW0UNp17rF5w1(PzW%eAFmiXHJ)piKERcw^BNjjP(vzAS zpzYKf*}ccY>hMjYZsi}1Cx;s|r=w7VqGtzA<&@9BMKX&PKiiIy+y;j2g@)S;E9c)K zOg2VI7g!ZF@)yT5C2qAIo9mwZ@C2qJ%iD76Hb_jTt;OmyXH5(4JZh`_s$^ z>X!Dxy-eFqQ@V$l+WagXYjk`09qy|A`i7k|ATV@O^~a4J3XygK6)}LLm{YFq50wzY zZhL#i{5yiDW#!t>V-$~BipJbjalOtT{dr35am65DTdW7{N?qmc=zxarL%*X7o0oKe6U-1j2V&0 zqL$kC_AQ%*e^pziB)G41R@Zuh@o$mCA+qttP2XqR@zOx-|*Pfjm(SSrlh#4*_Q{quEdqU(gf)8g&tk_J@ z=4$*L{^&grGIqXJF2{30e@BSy-Yh?%vk+QZ;lW*c>pozuK&4zch-e7)AYReZD5`vg z5k5c52;uAj-2hE)JOMxa<;%RkHjPTKxCqCNM?9YiXLB6mNW^>#L`j}lstl+-+un+= ze~_WAl!j%*_l12}{X|v2p>y{Z+H8mgdNev=F_^YB_j7YK2wg42M+fo-l529160M}6 zFfC{@Y%Jw4?kx7ps5d7o(cQg}N@3eomX?56;U$iU?5FYpZ#Nq1Rp#J?pFT*~bA~Q9 zn_D!ob9A`NG0!1waZ_IXZSjedmwr}KKh!jg2GHy~Hx8XHJJ9j1#`4Bv$vX686D8I+ z0i}8L(@R@3fA`#)QZ$`HyjFZQzpEpu7H(-Y_ zAWQIdxrtjHJSG)vJ4S!gpVA+~bvTB^?VS_+7UcQC>}$zME~AH2%JPEZFoSI>W%&27GS3FLoDACW%JgDM6~@TZtC-<2z0nX=@T+DXoRcc)M6*nsc?wMNYIqF2b1z*} zXWSp3sLxe%WSL0;BgB1RbjCs{?0LBdy~ zefjh4V&q(NBL*uZTgcY^vXAf?v_h7kZrBvM6+=b|X<_^C9u$N;;4&K9ppZ%#$5AIi zRcx~b(r;4@R+&rp{;@5hJB$y*{I=8<-RtdsTSMy=W5vHsv@Kh2YtR$|)rpJM3lZB^ zA7>!FAFHxW4D(;KwEup2A~2c+TJHnd0`j3=xd3aB$;psf%@Fi8qSLr0Ov?v3E=ZY6 z?7%Jbj0ftS{AV8sZmX5$j#|gm8DvN`XQAKx321&mD&de{+-JGf?3svn=wQ^}^;W4oX~D7A)6?8Py?Dj^=k@tK4rcoDpXb;bgW;DR z=m?sxVfS3x;^VsEqGx+Dr}Te(+!Q`eKjtT9&?x)mS}ZD~hgYf9JJQ-gplS<|Mx1c^ zk>_L%5oa&Gamvnj?e;N-JcV|K*+d0qI-R*mY?l^@OBQ9H0SlNZi%XD7&}6RtehdA{ zqC$M%OqF)HleqYJ!gnB`%5g`8X`uVD#`5$NUt~IcjKsi6#W#K@RC?+Q7fSj4rp07s z@|2n0M3vge({wuc$yi^@-iiB)76I(G^dk7<=obB9v01G&jk$YV@juTO<$Zm z&1u!^vu|ct`l$Xt?7e4LlUvs=`h)-xL}CY|1Q#GS1i?ZN>Jmg0EC>ox6kUk)-boM@ z3q?S{g$M}Jg(x7A8j2E%5+p#B7J3K~LJN?P#65AX^_Km<@7~|J_PM@&ew?2?Pk3e< zbIdaCao>oWddAQYOWuDg?6E}Sh9IY1_xBmwjOt=@A{p0Z{{?I&DH$7&HUNxk z*fOWb5=e5)Tin8xV$V^Cj8LBj((j^4O-|6+u} zhQGjDYXOc+Q~Fu#4xx^Z7jfqodFXb7#a-B)7V#(US~yH1S6~LB4L3Az^@k~QM4>Zn zpcBTp&$+u{>rZRHieOX$TivizQ}o*!=vowds??O5{d;r%^RvIo`dMqC1>hMu zb09H9Ikl0pKw3Mde+tFuVnF)J&-!NpzxTHmrGgtMkShOAJz`B=08<+z9(?9|uKo5C zh8F;?N4EUis<>lFYjmZE)PLJo|5Vn$mj1UVwceO`aLoRri2pD`G9~5XjAbk%jSnzo zU7LOKy@GZEQSZ^9kBz?!$SdBI*)q8f0rMp96;XhqW;Ba~3g)Q=47YSWAE$kG*5AF) z2DdqT>q%4Ry4it@E;h~LIwgJ!5S~q|9i^rbvz1B~jgEf{QaJLg0nFO}ZoC>Z1Ud3N zU7SihBoqPU22RoRytIhR_YFPt_C4QXc-WzcfMhrq;`zV%%I6gEtU26o(-f4{;VqtG z@xOb{FTBVV+IDpoyLq$__I_6V_z=4dZkCwss=CbhR-R0{h%DUa%5$boF8ATwFO$Qx zspYAgeo1UTe&XO}`%xxzE~+sp1PUA(Fs}V z!%a|6YKuzqx7%1Rkgfd}4%KcCF(h=1{pPN2|H18Qs+y*v8&tBWpy1?!HJ0B&l8(H0 zveLqWFwVgH7fk0BbS$NdQnCra_tp26N7~FGMw(t08YygxlT9t%NYp}%J2Z`$k4h;+ zW2y1ZG6y!N?l8`&1*SVb{&wqZyL2&+k{s%LhCRPZv?{9K?f7K(e*Fa>#k3kPk!H@S zWxmXz&6Vf<8SFlsD+;J@Z`)mjQE~&*VbBN_&DwhMye<|Of>+iWgFkXdB0_b`Ou+u< zIa{w-Jk>pH71uViK$y7HRqc>ZC61TPZ_{k@m zH36LrhhIM5Z-Z{y5q;O?E$P5zNZ%oxMNf@crzN;qYioAoUA&3eSswsr8|1;rkIU0> zw72~M&Lc-k;Z?sYh7z^FJZ+3?mXy`E4QTQbox9rorYkMUDo`mjt%ttM9&GY@xjm^j zP&KU*G$1{MXE}1D%v~(~R-y-c>`Bnr#w<}iqe1zhvBZm(Vuz0u<@8$H+4wRjrnR@S zPwTlZhv6`0^J=uYrP4zM4HM_q->K7qj9ual%`SK|>gbQUujLmOssOEOHV0gtp0&!| z7JAg;n<`_Trh}4JYQDHD5EkBY;oO07B`+jgK~w(8 zxl!Mn7e!3HYah2V$13@I7MYIfjrpf}XwyL(5ZjbBir}l=F2}2Ss=nAgC>!Rd8(vl} zKAgJo)wD{p{Mb8(2lW?jQ;hoxtQW16#t8a%+ctc9oy`7TWqp!D8}kD z>w^CPx7NpfJN4>qM%bsBW)lITr+oQ{*+q)te9$s3=$>Z75tFtyGX`t2sBhiwLZ?2{ z$DIDB@y#)=6k{iO+W%P~yu`D~Q}tBPY%mv&lf z(y;0t!d?Q)*8>4pa6^xTUuogz^#zeC@kM0iKFXut_k9FtC<7L6%LgmFuL`9}An9~2 z&Fcd|!b(a?ah4hBayfE^g{1A%zWAFsH(VGYhf8nx95RzOHLbF(dI>(3~r37u-G%IT@JJK3`uI6aO#UvBq} z;`+d~QB5HnD=cnwvPIW^HVYDqi!dKO@3KlS3<~YL{aeO?yhX z+KW8hs^|MgN(-&l2M3!6EM-P{z%HCxeePv=N`AM6mU4JfgQjR)@)10N?E^Mv;n^{j z6{&kq#JAnR<4209HitEN4rU+AI`zU0?LQpp)I(l{J@GR2PSXb}F1hCq`)4v&m_+M z&CIx1oD_)-l9njvS`POqI#)afRQ$|70#NsIyzpwz$#&P>*}*lNuDim`?`+ad>fC?h z#z8~4Yz2`$or2U(^2Qcger>2$(&=qA;iE{_E*Hw*R8}Rl-8tglek4zkHVO9Rh`bc1 z!=;DDXPe$_PIlh26cP0xU1|}n+8Lghe60J~=KXH#Y;)VLv2TQ;wV~o(i_E+COWq4& z$Vb-pu#QWiOP)(t7U<)6kRBDfTi~)!Av@JDsdGl}Q5tVIY`IczDGgS6FN=g$Q;7Y5 zbW4whZvG_CJnQx{`&^JCCnoI&G&Yw4ExI1*I2HHG-u9^|3PTm&3=W&CO^ zwW8zj`pXvn_wL?s^b<#;fhT}&8lVGU%cxfDp#xp(nji#s|90T|qss#+r}!P`&2}c< zm>lr9CwlMNj`EQ!k07Ht%!gT%;m4u+6T@;NN1wWBL<+6nEgg3Jn82Zv*BflI6T@RP zJFsqH?VT&?6qBS-$L}w@BP7xe+Z(t{UmAW0b`y)zr^lB%5Z#?C%%Kk!N8?WQdZfmm zKjV?N|CQ{k2j4u@>y3w7%NT*3*>H}X~1ZP)LC;WO} zR$OA5=B4mW@!L-xRDC8h;{~TFzTJ8H27I_G_Q9ZV#K(z%3!kjm^TK=c%#!NEdoRz@ zB#)bmy%{{EBnkg|aYVK8%Jg-okFn2w`S83t^9ok5%7f^=as7KK55WIygFSNS2lNOf zu0Wn~`2lrEw!SnMf~&lD9Zn2)*<3w$e<)uDt9);!7Ii{p=4FrzH#bG=7a<29sVx#s zT(=FWH@AW*g6%biQh+ojFxkwCxR=}aK2}JUK9{7J(;;>4jSDQHS@1rr&R1S;&z$!N z66?ER$3)+mnC@53UXmHd?kn!Ps8k+Xdwkzb6R2Haw>&C^Q)S4x*LM6^RIPb75uxHg14_Tz>56@nxSHT)9m_N~&L7 z`en0x=m>07>w-mSFoWm3P|D0h8Bd{0%br;+vbw28#BK(6pz48Gna04D3)UOo;vYM zYT>z0*L~#Aro28?{v_wX|X9l^Ywrc@#yzih0F>jGIBEaTd~H?Dpm3V$M(fwu~$W*o;SRtY6U0t!v4P2k9W2WN$j<`^3-igG`Zf_;-pIn zb{*tKSXEuQP4BWg3yBSfos9NUw+y_{tZ(;3J+FP9>+-b=Vqm^58ey&iaM zr^H@?H%gD+2p+5#6h;N#l2nAZRAXk39xJITBTp-q>)!~}ycL1`-Qc=%%|TAyUr1K5Us@6%}Jo)dU>H`~MF0s88*- z@))LhjMTw*Z{tm&F`6<0E^Df$9WUfHGB#@buC_98ahgK=npap>#!kx}nHFttzQT5u zo4EWKGBwcg{OTPwZXk53F{CbULG2yw=pkm+U@N$6vJ9PxcHA~KP|kEun*&G?>Wde;x*>5yin2(AcufMqPwg0AXwE1YT*0;mXI*c|drd5=>bKMwLEyYY_jt78*q%vCt!+V|}r!Z9J>e&BL_e zhNrZrh7S~H)bU$aD6|=$N8r{PR}gY z=BpBzgrc;ZuKfH1X?C3@+**HK(#W=VNs;;eCpTWGbrLn$D|&Be-gsH~q_oC)MAMsGE%4AIuOw-e){)){MA7 z%7}gA6B{81x$fxEx>>pBA_T87-$PsUt(6VU$g zsoU|p+v^ns?NLvK7lj^vlT)dBSZ(vx0#u;M*jqwc&VQ}Xl^|L^NAr!MzGM94lq;JSJ> zBu*Gs1-ceux8gn%|N6DC-`kzm5G+wQN(^@_)iLj3*bye% zmynI4IZO5gIgv>~d7I{pKL7sG*+(_P9(r%ozQNt)#g8?H&T!X=S%W##<6nIUCvo)R zTKshW)<42`8Y3juzNk<#dx7mc#+UsG!>gWs6+t?uDWwp>oi!RJr?+GEjt@=rbDs6F zQL{`UMKVL&p%LXEr-)y5BO}wQN~0VUaJG8d3sJvCOlX!e?7nHH7f^dw_sq`^E|3V8uv7Za|J#O@CYC@A|-&R5{8-TFgmZ%rms&_FIG^-M0 z#UXZwz2P5lv$b_qadvV~cXZ_*9g_P!W6DwjZ` zVo}Ej7L8e@bVpv+oe9o6In3iY0M~syN6B(z%%smc50zKtHsqL`OdL0u+#z`L!rk#y z!=WPq@M{;7#OgMk_e+nbxK}@6RQ|xR?aX~95gfJA?OV60V3_q{kn&+Q5?Ar0_nR(N-wl;r)Gp4Ey(Yv%W)V{*`6FY*l)-^EmsxP%`XBo#2qGka3+* z-EXko`Z!~}DX-mcxJC5S);m(ClqZIsNRHLw z>{{v0&V6>KRe|C2H`Tx;xxhypXPmL(5MzYq7l#t9bOH%q!WsN+w|Y1ot>#8GCXR72 znhH{2`-?!uz+^cX+qAGs*pll^Tn1xs^ZkAH(LO5m`c~yb9%@0zF(c*dlLG^<%WIZu z2ULrCJC{pr7LjqR8j;0d+QV>3zb0?8{wO$YYE#Cu3D!&e+GB~q{5(t*m(35%LA(o5 zTb1~lvufb|;4Ai#wBkNTDM2wuO$8^S?#;x*1?8L~3eJ4h$J(w0{z=ynaJ#ygj75|s zk4o~Fj}h&b&4b0UmecMps3g8@_yqph(LK|#%lImA$VAhWH-bA~;WJW5Zs6 zJ3tng0M%E$b!3)hgE{GSb1bdq3=v&<4Yei>5(4pt#R;pR8^}d_F5lNf^Fjj|fRb==23>JbkvrYBth<`-c1u8Y<&M}OB!g5`q~hq8=N}v9Rh>MVI))e{$bXESTpqEbC3!N|&4|5t~971m; zW%X5`m#un=lL-x;O9#p8Kxo2f7S;6GyS_Jb(5nCRT{W!>+QNb{FGQd?dR(2dJj!q| z?5oxI_l@jijq31=flkI3e~CZpIw~o7EBZQ{#K)tn{3gin|qQoUQd zi($LHTEfDAbJJJ;_ECauwK&1f_UVgC*9k;{EuXBfQuV7v_4D2LjvcU01!x>YjVBVd zdJ>S#LRg3X{_EkmVjG3y1;yWuG?kT3za101F^5G+H4CTkztryCc<8qozXQV4o!j?& z_;3C&GhXX;)WQ3pWdALAT=_~1uD$?s>*4qUyi|Cg{R$p+P74mkWrn(xIA_xI_W8Hg zd!k++lIk^;Q@2AyN7Nz6L(Kr?zIra8X}~Xgy1s2oe1B9FETLUp*gG`_fTaOj!iGH~ z3rADjb4l&u##$CIk7G(^qMGtiep>c*I}V1C2lpQ@T5733mvzTzfOx)PQ6{ANMtFxk z{1V|d$m%cK2djRP83mcxdl(w%U>)AadzdMw2|Y3>TAz8(O2p(c}f}7ZIh3|3RG_tRo{3P3iJmG z3XH-1S2E&9PHcTyQdJ^8p*RM|?>E_OKss(*mw^<*cO6if`P{i#rj{GgFT+_RjT*?_ z9n|a2Dcg?I%|0^SG+H-?2}Wf@fIF$hbKhxhl4AU+mKKsO2q*rj9t(S0gFX-Vrd{04 zx&YIj^gl_<{{Jt5zV81AIzj6aUKRC0C)TbA@&nLu`FteGBaMg;ab#s6cl78E9as_< zko&!5pkuRMt&3k@YRtRO7X1`|t0H_KhyU8!w7>NS^a(_-tNJ*7v?5EiOM<_x4y^)s z_h76()>`#Mrp^HZg+-%Y)c(H`6z=L&P8yuP5venO5U({f7G{*vRA!##%R zs8nM>1Al30$+Eg%_SOE6565V_W8%lO6lAtAA5GVj@5(<_!JED)s(qmo^5x#G)5yWf zpj-S<+e(*99l1&UufL$u4#veGQT$>I@3*QmNE7@@4UYb%sH9D6Q{E+^{^G6n@H-k& z@p>YPGV!Mb3+Kp9>po<6ci7ZLmmHOpUugG!g1j0J(~{o-jU6UD2%4zbXB@bH=5uP& zbz&S)hG)}22m>CA?^acDk6+UGr8P?ONMN%(jgsofFpzuhI~VZ*pW;O)o9Q%cG?ehH zJ9iMP5#^z#&BJr|c42GYxR(z-TYv74$Y=KD9{ zKfBn|%00A~>H$ga_P&q3HRT1B1!Zm8Efa2nccitZkhmJimqYqU+QhJEarWKM+~ELw z7FSoN`&NpWr_Ub2?wL&Aa`=TYZh=svky;95A1<4xwUXuVk7 z$|=kH(`o9LA~x$8B2f=A9z7kkIIzX?>>=`kapNb?UMQz@4S1|{6>rMq8 z5lFOi7TtKUW+pSSs*f^Zk`-`g_1E_j+vBUf+&5H6<~J`R(8;4I$g_&^CO3j|p3gCw zdVxfPQMX%{@P=@Wa3(PeBY{5Z<*}RR&nFZ;`Ak}ILdobvCSR!bo+2Nts~_@Cb$lWW z>4u-~>6^D4u*0(^um=cEDCw(W8&1!KT{2a2d;E2wm80s0h9}ytF7`0Z(qCXt5uEH$ zC*TGt@8Ch_& zpLh)!N6i*32I*?7onaY2c@m+Mzw&6+JnE#ULJ1ZUdM{ZwBnIp7j!LoM7cJwS$?$%j zo}9h5Us9%UViy2whu#*miuF56P|skbS_EccaS@vKVM^T&3i@s=n=A?UcF56ogL6k^ ze#2@oS@|sM@cle9@0H-R8V!SOIN3Ay!h+K*F4&--&2>(;^4fau+cC+ZA@4<6t{Jgw z46Z%Tn#s{HEOV7VapfH8eTW> z;pCq=S|1ke)^NXa*EyMQ;Kht-UUnv4th8Uj_vhXhT0ArCm>ZX*L4Bi#(_&v4$pIUyD8l(sX}H zo!pMWB0qq#g@0W{Fk;u(p`WGwQxO8}tUf;HC-iX7)KIq|zKaq)o|BLno{cNAaXFK=RNl@tjTo!ENujc&cE&`tbxXLZ>?K;-vALOq4t>++t zLd(B&h{S*FlYl&C%qmOXV086%vrZL{n4g4; zB2EHWO~}hYQb^F~zUHPxWsxI`6!&x!`pxXYA|CN%(3g~w^0rK#j-3(bh`$`td1vk? za%S0tqQ*hn?rar75!;_Bfs?t-}GJCK4x?H0{Y5# zk~Zax9Ix|I-nlu7cS^MHFg9k>vMJqpA_JN8;I~UV|Jt`Ue@pwq#El~u-rq{lQ8e;vnDh3 z+8U=;oyTj+1_l^zNj9OD(Pv||&-e^}?Nofsl}I;(ulVx9#-39@xI4q)A=&+8MWYXW1(Tku*W~;~H_K+m zWlTPCY^gOi=U{BxR;W?s$rNE_c5MQg9cf;?3PM(ZXI|j<*O9YfN(9bO*i%Tvg-~h6 zU57aVSm+&i9q1vuktRT;U&3edbpg4$#OD&(iwiCopa+!lAH}~ zGrdM!YRB|}3=Mb)12qG5dm7hV|7%PY;0arERP7}|ws&(byF)PF-%;XIo(3b4Z{U|9 zPO49)t8mmbc|!_1ZyO^bVy}uVHw+CMe^{dvK?MU?P&jqW!P<=zR>j3Hy!}oQliVpo z^K3UO))aJS2bs!nrTI0v~6_BR#p5 zs7B4vPOL^uq?dMKY%#hasfN-I&YeO#wzcrL6LFHftzU44wG4iqAiuFf7c!!x@=qLc z?#5M83t^Zy#gUiXamP51?3I^fNy}@QEhe^%8O#I_G`rRxlQ%jKU>JvIYH9qnAGAys zV^n8zan_+dyvyr(_q=&T-rouYpMTG1tmVH@Dp%9lSi)C-<;=I7fB5?-dcYk(zhPNF zOMvn2`K;RsMmv4^(pavf$3(j$3Fyc<(?l+gS!U&@VqCzW^lA)Rz zh|=2|uqF>?i{ z3fT>C__}~k9eo-)qijRut~wlou4&JpX?-B2;NSn}fsMXESecs#@u=+2(5`>~U9}hf zsDd-x+U;;n% z9;7*H53-%891i9<%=Evuk@|00BM{0|yXDA*4DoUF*cR0iA1-<)oz(6zAb?ghVwp+Tz5MGYawzt{L#^t} zGUq}O0n?}o;`s>PK_(}t17VXHu-C?F;qK zNu6s#c=r#9LL=|&#QN#?EX?gc#N82HmEmj*sHa6O>Owv#5{--^s{zMLN~dYLZ|ICT zdJ90>afpqK7e}`2Ij*LzZ#S2^=S zYimsUeVxT+W^ZYtX3N0xA{p#Q;3S*HXyf`TdT3lD=li92!^F0?ZE`t9`}TosCst<1 z{XYNTXuE2{c`p62L+0wQ6CJn|zqsh^$lRB@GQM=;;m@tOV2oWtn4jK;(8L+S1|NJ{ z4aH93g?n{aPNkXdKVIPrVC57&)Fekc6B7mkK^|mJuZvDClX=*MRoHiUpIB4|M+TFA zRVHeo%i=*9-oYPY1`STd`CgubJ2E3k0BcQ1C&t|4Ys47xomonHVg zsBVukH6!5iN6A3qkOC5{K<085nuWCgAyV}Z1cs0@Ry|Y* zG8y|EJ$P}H;zXR!1$47iwO{08GGcLCvH$F@y|T0#@*O633;b4Aak785xga>Pj!S>% zPUq)|F880y z&M+9#RF-No6{#D|KwKI60d2qRwEdRiUKvQM*X)e_P2;9OdB^vox=_%TkPDqR3|?nx z(q>}5!gb6}+|jJpYL>r_)pc$fR4(gQGK_GOa5tzaz2n(6Z{*=!Cn*UrxUzEsL=$rS z;K>WehXi0GNv(cO^ohQ>CmSGRT)=!2|J!JD+ku0`9E6(&op^?oA%+wSXd;@tkNbA4 zxvoW0k$=^bJUev@Zh3X(@rEx6t#BdH(NwWJ8f{1QgpbktqRt`i-CoY&(6g6;sAnF0 z?|z`v#xy$sP6Gs$_dnjC%C7m3c8*F!CN6Wj+)ykioyN1YwB%}d2T8WO*YN%C zW|P6S<4TNv1v)7d!?TC?e1XobOukx3=m_-&Cqzbvu+U)sFQ+bfN2PA`q~@|^SFdCD z{+l#!RgfOOmOPpv$nQzd0sCqa*!=z5OmQA=bqXs1UV$Aj*maAY6ZrYLwWpwgZn8n%S6RI^;3!pjP{LopLN?N``^S086CNn*=VZKao3Pkl0++H_+O#j=G zJ|RsLAPtO1s4Sv>7Rn&lQ6@}MIR$#&AajNg>z&Y~6Lw{Kr2~;^)*TiJPEdQZA3!%! zpbnb@a|uCBr<#H`qIEIpW7EOC%<*L>-eu8P!MEi-$B{XEVDhCE3cu{CAM?O~`?bph zo8s1wPGTA!yH3Evh#uNaCn3$t19EYPp`9fa2;p7a=tIxHVXS^Y39z%#9gOv1M%+En z{njzxhxhfs)tM+%JlX8gD2$c7PahzN zWCbzzbK!+ozrI^N_De&awWpw%c+ji{$Dc!0C|Q_X#E$}zsN?CWF&10T6Z-KB<&}r0lJIbeD;=t3SE4M>Rc_*qE6nS$N$jn!UQaJ$Z0Z_b z{cR%1(y+Q(KOm9qxD8oig{K|`voz^lqaBX1L3seB(Wpw{*0O>T-)O_=yZc{f;j5Ak zz4yHYyCZNVnUarS>7 z3lF@CM}#;M@E3FzzmO}xIA;#_TH8L%?xZj?P+J4Q^~I6=A+}Or=96Eg%HyS<==dlo zFuEMRI_$QtO&+ACrG)Yg0_r6d<%p=n-~QIQ5@oAWhu7y#X9~S&hQuqBw{}*UwAY1R zo24y<)OPg_ayU{#RomQ-L4{E8;t)%JK*xE9Kh=)WPN^Xn^&>HJs^3{zHcq>wCD&D$ zL#}Ye@TfF|X99C3pu6!bc^;q8_a3!%<&VnI6$6Lku8V!p5N<-`7|>TZ7UJ8h2(8p2 z{t_Z;)171AJ=qra|K|(npKbfOjyAG85MG3N(PUmD@bW~}kJSrTkB{A~mw78Q7Uh|y zk!GNH5TWpB;&rdT4aIrdtSR$UqOtoi8endNsuRNVn`l4!aO!c0dl56{S z>$GAFhgzf$?ucsB&PJBU;4AT@#p6hkf!%dZ5M1UUola!4vR(v+g1q`Te&tnJ^UBa= zvzd1ven59ECLL7eNrgMKD9J5^Zq-kEWj+hQA7Kf&^F_mZ`j{H{E|6<xwDkrPI142cEjo zxY-M9Ahk;$pJ3=qFv;K zDtG5ROBC2P|Cw-d**y2;^rdA&ptI@0A&srS}JS-yD}0B#us!Ts*LVTxP)moTlrN{?<>krlYg0a>t~hM z$|<1o5v*KKP|mZ(e$3DbuG0cewFYS5$`>eif?Md7ZwSyuL1>0a?>-miN+`&y6PnIk z4H+OD(hNA_%~fS-Wu>=-GP#k{S>w6s+znyJQHV1Y&2D->pqAWrFiMWos8DQJ#ra1eQ-h)Diyx=678b}%cYDyc`esa2NIW{-%Jz%jmO_bA=bG;Xkg#+N zGrZwB*!`!Z1k+a{3gUV0{_L>;jT&LLczx~7o=IsG(~*t2Co{DgLjD0A zis&U4;AzW;(#?VOlm_!1xx0NOe&q*Yo(2EPvc$K9MLz^L%RU+NW;|8|H*akWSQB#H zfa=ZV9ec72+%Lg~*royQ8TJ^EH@COgw;T77|KN6}{}&wNvud~dS1?Km@2{}{#X>6O zY#58PdCJ<~Jr%>=xMpOwAnqoYJ*`VXtw2*&eT;I_`~wu?v~~hJeKsq-jfojcXZ44D zQL!7L;BMlQMnDr}RrAq?FVFf~Af2}ng8l7|mW0_o!8Ay#W+4k5*+_=bPs5U5dON>L zmf&yv7EI%>*6~iFZ&fO@L8C6$6!Uxl6;RXKr;e`v8WfS}rb+~(cB{Jk#S8pu zsxR3F&fiEk1MC+vvE%7I>DnxQC7o^8W!tU7bbJSHT>E@h8~GdAfYuXzk&if3lbbxs z3qvK>y;m*kTzBi(b)Jy6Db* zt$Mr4?hAGP4}&61U>cJ3pR1Zw@h!MUKRahf^iQ+7$i}aknmM@Uo8Me0;j_rE{*IcB zZ2Nj}Sqkhuw79jFm~rZLnh@ypMD zflXg3$CK*c2{Lm)OwpSCl zOu~z4&iY9gRyI909)23UgZZW$V%Q^&XKxp%nxa#*+D>I*)& zZ6D7|pN%wOq}z9~Dal|@%tp12EIlq|4}{%9gL(HQvJ2SqE!yHL^#}Bes;hMGpfzD0 z)ZJ*Auz@;klHSUX<#r-2?V6$Hq;q54&x1zGlGZq7e%cPyeOC215L9kGx>^^YZ)e0+ z$K>Tq0BMkg)g?QRnvx);!ro=J&)NW^cG3yx3!bb7z(->0oJkl$-+uSSsg^J+1jhJ? zJLXd?9oR6CYpeQXS{72f$~QTNjL4R4=Dby2M0Nf(j-hBBPPThzovy5^OS*kOzl_b( za|5lC=EJ7-g1?oOEiAk3o^=^Z6$ZR5`C4q{1x&%QY0G3jC|4WQO)7JL*}NsZstK|F_8JZZ0sSFd?lq%` z>>0`68U8c$r}OPqq14$6JmaOugi+Ky7^ZBR(A$|)e9WlH3#Z&7PS~~S?Ugl0`^>mc z*Cr&Dzj~4TFt@#m@4k3qsW6iO=B(0KC+`IC>0oKqF!?#{5kFlK90Rr-Zygo~{+f+7 z1$4L7#l?4PMie}Qx?seJS~HY2Q@mXjx@=NA?FZHddX*dcL`n$%@dRM#f3s8=s{aF8 z*|E0Xg$zdN=c3xd-fWq`|8<-B64>FuFu;I#xKI2*nXuoI0dfaSB)x*(!C;`=9qBl1 zha~2j#<$j83m1Bz=$)p1kkqOXgV3B3^>U^6P(v3f912rC|H7*KjWS@Rj8GN6Q9dHN z`&Y+tXt7CqsSXMiRtu1^yb3s8eGljMtkP+02992#_*Y&^<1-@#tf3wo5EFx=;~(7&0tfM-NM{7g@BexkZ+xkarGvgO8uREdtP~*s*7$J8jZti zHEYR6Ls#%C{v(AC1)X!Q-k`m9=UL+3(&7N#t*aVvqpfOMIFj9tlrTw2Uz^YaOA2hq z6sSR8a89k==UbCDHyV(3MPOc0onjoS!!xf@JQU`eGsJrTIt=a>=*|?`bxE&znF|j1 zr=i6cs>aUPhho8BhwOK2vA{xUdJ=N)U%7a`{PE|MFL4O1Ju!O09SEV<-(FpN^YRoB z7LKK_fS?s(pVP-WqC@N_o;N>m8Ja9i67J5tQJ^bFD2EoxpUh6vva7vdv1o5wrzS=sdoQ<9jr}OjEALX`(;(BM*GnY z+TKpv8tp)8u4U+v?+BBd!=D9AN)#_g1)dpyDp0)zuOv-g-@-^8x^uXB0|h!xAd^(99SXkD*M23&L=GWWBFYqsgurCpIWmNp$FLa8^rQyml? z(y)&#Y)=n9HY3{v-Nw$UZs|Sg96;X)J0auna*VTCJ1E%Vg0Ty8*~Y4sgd0qzdT0eb z`ImST%rcJ?OEcI`>X*f|2Q7fTZ&BU%KcbNEPJJmSi=f;GuJ$#1e{s5~6N4C(8Urqw z!L$^7Zfl#u2@2sGgl5g@xA!%MA!GD@8IZCXIHA59-AIuS(;o;LDZf2nQLtP|CJZdY z7LGQAUYM>v7x(N+&y}V3`<_U6wCH-$fr7ZBG-EQI>QD2nE$T=5t}upeqz+y7-FkWS ze~|Z{aZPPsyJ#Rt6qTr;fK&wqK{`q&s2dOzqS%lY6%-NaAe~UfLPtOmiHh_Rsi6m? z3lf@i2pvLifrONOCVT()e&2h4=bra`zuy8`nQP6t#vF5$XFQ|AGgu+wOW|$YbsOx~ z)CE_AVQk&7qt-GR_ks)^Z-%HtT%bbelXde$%M35bkoJEZd7r2{p$lZupfi^__1GTl ztcFhsB8B3=snPMM4Q55yH@ag#SyN$Gyrewo!d-L80zp++knUBfl)7q26S?_yI$fIT@RnlVZ(67nm8rE=!RHD}VWl29Knc&Ze|DXwc{BtyY+3oo2;s<>~5C_nK;& z2?D#grhYDOD)ji&=7$c>kvKq}nWE{Z7H)MqYZtXk+PqevR@&E=v)JQ6M8q!m#?uVG zGn=ipUGiq;e5_RK2u6eAn*_`Ez4}B#m)9t|Zn3{aI7vaE5_8XLH))2Y_`eZF{NE-! z{*6QZ_aFRM3PTVAE3!nGI-%KIg`0&+5uKA0oKujQcH6`^s$>i6w)qY@! z=6@^8sG##;9T%2n5EGKsBCH)w5ZZ6dX>bl7mZo4AJ25swqvuPhZq#k+vBpWm%ePA9 zMTPUx4M2PTQfYJqepIaiF>L;}glnb5==NPS%~s0JyD<5^xYmS8R7)g6HjP8xM}4_V zwhMu=ocaV~fA_WDuF`L8mAt{vz-LElylyV7rx)mn`GPtH7tLgU2EZALU8uT}^||ee zLX>eR+ML7T9M${2fAF^Ra%lr%?cEqBa1#8`|{_Y2yhes|sUe-x=RM?pt3l0gC%z5$*5Sl^4Am6*PU2M45* zP=~A!zLFlickAt_-eCHs-sw#`qQ&wTD*vNBS}Tb|f%wdJ!r z53bK4-laFn{6PaNb7k9^XtvWs5Ae)hLi?;qBEF=?YWd^)Fag!c=$t*T?v5AW!u@NF zgXx`&E?R60f!jWs4i0NsX04Hv(gwHSNSw{BZY>f5HXnlEW|NpYVIVM;F2~ zKGQYPlF|nU9XHTP=s-u#$^7w2923WPHY4v_&8=Z0R61)+XYyt-W@am+Rd^BAvAA}s z=Pilh+xXGAKd;{{0Vnwlx+w)5SD_H&X-jkvJ=#TP6&PHn<0x@AFC|7=Z%Nkzsu>-D zx0PV>nn`<05JE{`1L2ErH(M(4z}xf7mwi&fv?c8TV_LGRNnwE0yZ22!J#1-eZ+$Uq z?<_e(o9PEZLkMI%+5ka+Rh?{)YrBS8;r7hl{0&Gszc5^ZM>^A-n#!K0bqc)_-(wr zp5&j9;0&tS8s}BOUAV}!G~>{?`2tZ*{YwbY-KbceqA16rpR0IS%Frp?1QsW)4N zh;N09!tS6i0#WwIVs@Gk=Q&hGNxM$WpBLgcZ=>Gw$t$KMv>&%$uWY!A5ZbVhJL{Jf}&(CEQNM0|f`jmiOLF z5_0p(w%wk$_3tWQ3GwYG&{sHU=({NDLuU%>C3+$vQ>pjSwg^7=*w-FkDuJ@{v;_qI}*SEemM^Odc~ij#-$iSa&ZS-8j* z)tz|S?@!1+>M0+k9n>3i@{PWAp|8PnZypOxLCKcy!1Gz+XDG1KLqEsxDG5}Qusz*5 z9M%~~o#OjPud=2mUh_sUf5Yf!ls)GM$zsAJ5SSA~107`Q&${hgljTi9GsMYzdbef* ze?hpjYfsXo2J5N!$Q1Cad!OF2kh#lwJiu0qSE^x153h_MDFzM%@cvH|QS}=o|JjFd z?lI0#yqa%LKHh~M%lSuz1+~)u>YfbynQd~r=;44SZTF2?rW7D1&Q)o;pe4;?HqZ3D z_09wS<1ha`394GfGk2l8Cslbc6JSLw(cXVTPQl+`)p_>d@ms8aE7ZR=$X~Lx=HW;{ zbVUB83@=YxAk@*zx!|a+(i{fVys0rB=rAg4hTyWzjAI7~iW@j;qL{nDKJB@2Er*s! zg%39GNh#1P=PE^EUb}L_7*LArB74o-U)gBlU+rSCJxYm2o0`AfyZE%roj(#Zz-pbVCHQ% zKp5dr;&fn-sXNZYu`3d2qp(|qBfkJxXPOz5_}59GnPetwVGLrXCJ~dGhU}KA@4O46 z5jU>{`QX5-s805gA`frQF!ndUku_oTJ3b6@rJj5yf9>&RfdS@e2L9gWpf_CTbT3kh zdtjRlErNhI?Tsru8@far)v5q{wqg|oNW@NN(APdPDTs-`jz#!}a8VRk{6Fj`%_xNS z*i$>a%y%w&?Uo%N__cV;-Mxd%f;hp{&cYgPqZ!#k8v2<9RAVcQVehhF zF{sd|9$%_T+d*Ffn;r zIweHVS_33U{yw@K7l@lb=9m=6wX~Kh^46aaX)SQv?VS%O%GC?z=*(oFB@hd}qjhzg zZsH;{T2hI}=;HH3kdyY8RNQ0GW4viCiTA)tmGhuF{)Et;olH4f6BCpf^Ln`wk?ys7 zYsQqC^BjLrx_+?fm)T4jb^|qQZjUn6;k3_ZO_{jL&=yYY?EIXF$-~SG{j%Jq5>bpZ zM=(bg-p}$iAqF2(<9-RmECc(a*)5lbr6`MPo$2M?D2y_nqb7GwViMC5=Y?7?6rmHR zn}oT?gjX2{(JvA49Ot}t2blp`O?|kwjERS^*I2mr+&sXJI-AYc++^JFQQEp@G|DsH zjNw9sv)_k-!#p*au>Z5Ky9j+i)8GmMd@GS8#0-)uXK#pdJtThjE+u2y@eF%9bX z_An_y_!SWUy>VZ=O%31iTiyg`tE{n)BbVhhg|jBm{#h|AO|{=*ark+>j?=c?SKNG6 zaZMCTD+BlRSXEy^QF83(&5i?0h}!L)MmN33^QQXt4DU~S!JFbHSFQ7ecbXUDdg$ZR zNqBffRn`d_{;31IQ~3a^OYUNVs9TvI0*jm*4xbHBl^C@8E&XY|#NlY9b2zLO zsC(2#$5SY|emvuK@nRIK&f|T{xm^1WzkIJFic7e0uHntqIj`~QAZ=9$#9Jse&-Nrb zgzQN+%eimuqu*dX%Km)$(HEzQ0XH!j#qdLM8v(w7YuPCHLW)e)8&{5VvDSK=Ww)aZ z;GS#c`$MU<)OZ)CTH!DFgZ^A}@k;zlLvvC?heg!;wzKU0(K-?B$e(igN8dk$y)!!< zwzJE!g2Qw8b9nkmm+*02mH4CS27%l1zEowKBwx8WZ^^?e^Y4YDfXYgbn$Q%ttO%}O zbT}#}e;_>bkt*$5%2gPzIjfx7OyJrmIO@=dw=rw16zRI}0$kNm*h5UK{&GzSfo~J`ApjjpBhBL*N zFFfJp9Z&hBmCQukx|?RdvV}$y(dG0w=*7kR<>l`URvLnv&vwp;pl72i=ilxKS@|xEvKwcrPfx!x}++w_7{q*mv{?~zK#oiO>qXSF9uV9QUL50@Z3zy9H_Sw|z}XgBE|fD-pT1y{ zaOp(jlj*f@oIcKgLVr%R%kaJ>@cD+e`!{+y5p|Z*J-&!6L*JHiwn7`e4i~j#PE`RL zyp#5~{1q~t2N?8~!(>pEr*&qGl8%zNs#Df-`|!q8%s@;Jo}6}0Gm)6bxKI+XUg)B) zi9B2MFJc;ghjJ@#7}R4a`gn<=!&&l(7+vCEDE{U%F z)L*4KK{)X1+kkn!r~>zoqsbv6M>ikXhzY4eVUbY#Go(D`@t*#7i_?A*c=MrYTsr&Wl_dl0_F-Jm>}S&00)e&Kl!oe6Ya!5Pjl!ECePo9$>d2>!D z)v~WTqB=og2LEvX7O3B3-zI~qzZ)#o)#yv0P}K4NZ(j~D^eB%4c)Yd%nj3VB6dFg% zu7jdam@@-F0tEz-zJBNq3D!Sm5{1Wr=1`9ZnZw)o6(J^Z4$%>y<=t1M$;isQ-l1*S z5aeGj3i?dk%SdyYl-y9ZUj^ywKqC6*_>+tfw~fi8VrJ_-QWz%gx$AB}KUUfTU&P##B|Wuq9@?;;aNG-~cW2K;snCV%eqnHk(&(ZldFPM5P+&tGwT+_sPJdi)I@ z5B(`2LE2?zqb2K_&C7gQw~uYM;;&iMO=a|riITywMhbB4G7;~`cYWV5@@Z~9z{^{M z&=dMJ;2E|a%171Hj~S=NYaSpq8-WHXLuCo44Xb5$R{PB?naK6|p=bT2HBb1~Rua7K zMQz@sJUF^!OS=zfFKGDj{f>0e3Y5pqsOwf#VS-At5+nCdNWz~G^n5goE;@uls>xzO z$r0K>6Z}7M21`Y)LxAt$dVN{RW*!b2+US*HHvAu`czPJLGM20cN)g*g00$yaQREGX zmS8tc!-gonMLjIkhyKZ2r9i~@2DJ%K8IYpCgZFbYz~LP~Tw1z1z>b022IqVh4#WH7 z^JMK1GyeE)+&C0JAqZNQeiL1&6>iBvV;rzxXn#}iqdy@>;2>Izr7j;`@e+fl_X?m> z&Z!kK@8YpawJ7ineznQ}BR;!p*@li?0RcU2SXgnv?@78R^8t9*U<3w{kqJUT`?nK^ zu0~}xp-=M&&jFAFc=S;a$O|A+^tNhJtWnW~c^JU8{pbG>HZ8#(RchPwZ-`Xx>q7v{k=!V0i%C$p&yC?J?Le7aLCqJ-BQGoY8a2FL+ zNIC07>bKLB#UT+3+miCLi;m6Y&J5)cw@e2%_NUjl!Xt>kowP91x9xuA46j-GqcHE2 zib_`J80b3XVsu!k!kBjZx|c(JG|N<3{Sh_yRsmMmH%+B7&cP);w7im`(wv^GExKx= zans}kN1g;OuVmQ6BdulzYYmdasBuh4iRYKdm8%|w$<$X0Eaxu-p4(p^>>{J)y$i2Ik=YGn0ava%~QE*VU&GUHHKUyiXy)@*_5dZAoTm$XBa7wec zl*UeI3zxFuKt8PfNei3RbDYEu;3hsbh?ya6gj$ZAis;t!;2mj{`{I;+ zBS&HRo!WQ%IT7J~%uZsF-peYU{q{1G=QOnkW193t;#zpcrr0hj$k}o9OuzNFkgdd% zi=Y!e`2DuqVb5bP?-vtDb_2`LteB4Qy7m-@LvC33zWiP3iy>3q0l216H$Va0KgvLV zId0j9Zga$wuV2Q673U*-;N$4`9PqinKi5b3&x z0*pUEXtcOh?@0`B4n+8Y#LRw3_Uf7=F(u)ozpn+7Sd6EVc;=#Jb@pTz?eQlBbQsc1 z=hSGa`Ig0S4b&^aY~U-IkUfE0Nd%_pX*wqwL<9S{{}Lh^o^}J=@Y>2E^^m_H;H2{h z4@_O8_}^tgfE}Q&fqtdREThl6XV|$^g4p04hWXxx@k@J4VqV&lO$1_tbrct}MN1Be z)rdl7@Djwu0_0Q~k`}hoJ*a*P`e+^++tyC()ZlvJzCSNR@@U%NCQGD?R&sbs2G$`= z?fq-@3&W{3iJ&YcNvlY-w!5Kmb5QA1vG)pI(0GhN?q530v+cgtlsaM4J2rE|@w5wf zu65#p4ateuY+9Z5(5H{ZVoq}`rz%T2Am?&Wv0Y1e3%KybFDH4!cq_5j+)!)nRK*6& z;U%T&%aihA{-%|X_O?jgz$W-}8yt)eVv~}7D(n8)<@~oy7)r_!>)NJY;&-gN3jyraz5<%FdTV}}E8iy>18}ymoH*+Md_Uw0-jh08b@o}j^62z`^X zxOVNzT7J5{66t2IB6buwMOy^u9*d!Pa$xdY+YV~0hp?vRwB?tZySxb~tZ>6~=X_!I zTz}N1ibP-C=i(Y3e#nh)0WBYAVo+ReNdsBLaSu(?`@VuEQcb@W8D3~p(f%sG;z!DRyI+8H&x9I0Ly@llHIk4}uj)xZ(w+GT>dpE_p7Bs=n6k+;XyEumw{$BdtC-^0@p zM%WFb8TL-Mo~W^U(vAZ95(ug1fd;V5gY@uGj(c6rpxq12sdQxBQ-pWj{H=B{k?(gev&C=GU9@Jxu$_yeZ-zjyRGi5g|rJkUU- zADT5m(%B#gP_6g(Ocx&G>JUt z>|X6YCo$ZmmhHTO<8^C=5JW6whE7JP zseihB+hQp@W4eyF&2Nh6G~{K@s$l-Wp3lOrf|>7hH)Cmbssf-57m?`RKOt6FmVx0S zM&W>*j4LfJCZH{JAR_i#sX=(Sb6lXV_%9+CU;^f^kG}eSRC~XmAPdJxOtP;22#+e8 zm8!@CR+i0cbdAoM-nZPH6-5CYsS=P_1sB!jxM(NW;9&yR@@9N&4`KCMTTebtt4Oj< zVp5{~SI)PwJh!DhCejRJL04cTX^2l6DyXKX$jX0FI5K-i@{5N&6(f{!n1D{SvtLLR zmpDrk-yzbY;PR;)7iRWbwpO}z`KzxkY-t{Y4|>!da7*YOy-Ya021_s~P_b0dhp<}> zfYXgb;iAUoEIJa;s-ZsN6K`?$=_k%5h0Hs(H(Yy0re;L~kWjwYgm^S6aVs)hQ`k<* z_Hh^4rX9T&V3u=s@U%Fe$Ppt21$Lg4YL*2~KdGcUNrsD?=-*Y#l$7d2rCeG2N1u_4 zjA(ACgfYD?G^~b$;?ZbY@+pGe^+#;g!|*rnZy2ez*%ZuuZdRT5w4Horgq0dpChd45 zL>JaS2pxOLa;IX}2{~a=O0A)pWf$zc$f0rhY6f+8P1TfIDLu=*@x!3p7^xvIvV7({ zq;)LY*|OcDdy9!7xNg3AEeRN;MkBC?m_P8GtK&}+Z{XO@wL$qq{eJz3&2SR66&G7> zTj70pJy_kG5y?C`UvwVs^W5ucoz$`9m$l~)Jb;ed1O9^Ssj~qWl>USapeMG|4!PvD zi&T6+yuoI(e_F@{`suT8!#=SDh!p72fge)db^$qrK=Qy*wn?<8do}r~ zhxa*W599nn#4IE6N`5JaT(E7Px*a8lit#34SgT;UPx}=9SU(?ejQSDsNM*>P$9Aw7 zBPX-D0qxAX(CKA=zhqz-AbOq%hwWS?Nsm^jr5>$(7WDH);gR{skNT=?@>0Q-B0(vy zYg&VTr;TFUsaBKCb?C#MF1wRb{I9Ha9&vG`){Iz*vI&g4Xz$c&8TwwIxTzyvQ-l7U zx0v2coVhQ=b2NB6?wwcOZ#(qIVfU}yqq&%l*71X0V!rx(7E7r!1mtC1)z(@9E?;fp zepyC79;{EwcGu#(CvS4$kvxgU^f+68KP6K)?>G~RED~2C%S49? zu$Ir1BUTx{HK^duErnd{VdK<0$;Q8l>7Ud!@#8Prd+CS=yE73HrF>_jWnjF`mhXAz zvNSw2-c+5@C)`KnGe4s1$y-ln32X0tHM+m{Z1q2`W*~oKf-wai=_{j zbFjb@xu1AoEJ}$jupoSs^2q4)b%i<-f)ft~<>oVApOPuE^bTURU>u z4fL}}C#(ToLi~Z+PvICF(&QWWkUOGLAase}to^Z`jM{Fgu9vG{o~JKI}){%2_N zWJz8qrX()aqI4}Wocmd!f@n*&G+H^ zBmjG(*PUgo9itx{5ix?GkMY?UQ`qYd`c&}%+ViRs z)!g5VSim{2%fx|Kj!F_v<#h zz!IP7QQ@WULK`7o77&jyt~>gP$(T$1-!+TqMNM!JCI*Sz1U<~SRxte%T??@8`V`)E zsomg>A+ralEyE{yUb(Ry9T-&;nXeocCJ3iXK#|52>}0hudyr4_Ks}qI!=)Bga6(_w z7U+wc95vG`SH4@M_S0zcN=)2pfA5BCIOq)X$9D>8>YD0AjO^?|zG<(#$oEUS+Mq}6 zbSKO%kJ%$6|J$uB_aqZF+BA=R-w-1{R;Q7Fb>zjAt6)3d+hZ)VH|VJK5OJwY!x40@ zf5>zssJ7&0A(*uJ+sa;lvY8cfIMW8MyMmiFAcqn&$I#0r2?=Q7x% zd5j9fatee1!oeF-+gox}WxHe6|;;8?AJw< zDUv~n|86HNmQs3U?g*#1H>bC|KuQ(n5`b?=qd^S@NH zdIR~ttR0sm3*V&AvkuaNGaNTG8R(P$Da3foYEWDBfS;#r?U&IF1iz`jg(vK|?| ziZDJ5IrDF5nMKUF^ z%;yG4 z;r<&6jJ@<@Hqo|7y0N%W>J;1YNAlrra~q-U3a+| z3!>aTk%STp4s`bpFUslL2 z+C#?0lJrfABir@GW?nCnFNo;;Z^~hB{BIFz|MNd7tz%U?+$_X&bfNVE+5?pHd$DIe z4!6*-5|%nPpxT5~oyxer#y-v)ZRqv>UGfsqG7nu#Y|GEd6teo3j?MK9xj)=8Gx{Us zX7WryZL6v(;rj0Gn+S=Y{El<>ziED;bid4d^=%EeX9v+JXniUFzJZjK*K-B6AR`rC z5;037KqB;vpz$aPjqw|cVzNH>u(NTzQb@6%LWS3`^|X5{d50g>X&rhD04Vaj$IT0M)}?IiM71Qu7P@3@$(?@w0 z-4ykhlbA?_{C&|AP|nu%SEfGzQ6h_;EAqFYKLwBg;194MidPYHSqyX&=C9nkw(yV# zW)oPAn5MsRL#z?TTW;@}x7D0|Kr8x;aTZ3FPwQrsrgQU#l8*`~C)0D>fG~Tj!O8fG zidj!v`m&e9710!~V;)*ZghSC!sNL~={6|VXNu3NYO@x}}J9U55^uEJ(^+IA@?svNL zeYeVN29C!ELu1w47c@EHC4WPepuJnf0QAud(ps9j{B}KTuLR96)2@o7@Q%OG+yzLt z4$V6NjoRReo0xlBb@ofiz7#o6{@!3hW`}o>e7`7rlnv*YpVAh9GdYQ`_1B|$DY2cS zgU#+@z7M`#rzejKuzCkGT_m=h=krLtoRR(GiBb8p@;Y|j&Fx2nXAomz0rHXh%L}R` zy`q=fl`A`R$glOp9|!@^dTrS|nMYnH=!#mrfRLAXzou6$nHQSDQ^Bm<4Q!zJSoTWC zOl}?!QHu*tKC~X3lFJ?7#2dir8WVb5?PHf)k0Sw4u=M{B z-#zxDai#dh5|K#tjUfg#->Lg$^wBnFxXn;gdOC8kUie6jgr{@=()Q3Bg=CrEj>ht_ z>fTQ3e(q^6L#2&Ta}HqtzE*Z9TV-@Cgz47g?3lt)vW-QCJdIj32Y6Urduv%RR^x>?*XO#Lt_#ZCLl7wIM|vVD$YI zlWvnFw(LN703|2S9L>Z`xkf#5-t9~2&;no?mZERA%u{`7M@{s^y1630T=NeGo|o3v ze;_yN!jyad>c%zhdF?)>fv>(b{|Zn$1kb8vy7{PBZUYoI>4BhfK$RJXV5`=6oj<8V zUu(2-vj+$ zW>81y?`e!tlz(W6uYan=^(1)lZf=|shpK8ExjH^4^nui3`4>zNI$*Z#OnFtHl;`LP z5%kTH^OL(;2TOL*gC+gFePEFbLhyCzgkq&9T>J@%KRIr%oHv3%F`Fc0tY!@$MH=B8 z+nLCStP+2elt)hcX{K`bu4jw6kT-4GO_i>aJ7=IUPfSUqYa(>YP70mu@->?jS5|Yc z?l|pqO^^QwKVm5umuw~2vU>l#!i$QYw5`?I)&4Y4-V)NDJ#qZY*5 zggE{=gme+au`Nx6?(5v=zDPb7#2PVT9oQcpzG3Hi>*m`?%ruR-IWW^OY4aMRF(?iil4mHw%>_H!hF+4yuma6QnM9drTJG=DD!!=jXF(uWV_$ z;*xTFBCAAEtgFR*=?P3!WBQQG{yK3sx8|Dxmy*^)yr0z|+cMJe!KHy+*JVVx3MG@z zi3G>Ds6w&>!y^@V&euQHNIGZQW;g68C5tg)W@KFXV7!AqIJ{Qr^J1eW;{zer(riIy z_DX=Ro(OqIWe14M?9wE>W_||bA6_Ks9(B_l@4WP&GI^t zIkwr-R)5Tc0y!ZKV(RuNzmJjr9eD(o0kfw#PImsoa5+!N$g%inNLF@pS_13ncOYJwVN*M%wp5D966u zAMs7ZvGH-Po^?%c8*Y}m#UWxt6qgaf$0k!hVKhPCXuqJB$~5Tg#L|2={pKvHM=9-Q zj|Vf6yt!o)@A;;!Q65;}Jy(85PK#K@I`R6sTwNsva6d@2+2}%B{F0m$zq#tT$P~^% zRffiHGfzlv9Cx5Y(C1lp{Y&mzB#wkv16sV(!>N>!=h;wz*5 zORkY&pOyZ-K*kq76?f|D)?#Vh)Q-3&dX!%M^q&W4g$GpmzR~Yq0q_{hCFDftG7%Ql z9Q*GiB$Y!n972LKM#Eohpm>+*s(j18PJn3#47b zC?mL|=|2mmAiOL^q^=M%C^sJu`n728t@XzW+1D~#v{%y#)92sNX-B-ar=r!uH#-jf zJOG-Bfoey)Ye!Wy&Af@bxK?^pmairaxxhTxRa0-J5hK@1$rkDm%C)bB6Kp3IzqI3?um#wwH4IWeh$InnR<#=kmR2cNJ6lDvNP=GN{)X z*peHJc&=e?bxy3W^aZd)5DSF#xFKL$3v_SjlXE^ zh<@`1B$xwS?&e7LxW=#05@ZmzsFQ|HIOxraL!SdTh33SpA4Gah1yg_X32fumu?<3^ z2N|he8jY6@&Q=OoyYY!{HfvHdlf5c^cY(s;@5&{qV;AY+2Zc{qjv=RY8$b0!&W`)9 zO$Y82degkE!biD(7c;AGsZG2$IyQ^Ev&ckE`)CEj8mf=R-Rqvj3z?oDj(OY!IpjG6 zhsOFq7g|5OJb5HhL0@YT-L|{r_DC_=(A%=L!_agXVC!rADweNIVVdd-hi+waaY9f; zn{=3KT^pyI%jgD#r150f!@JZ3#C4v^M)W}nYqDSM7ngNXPzxWAdgm_mK}6`%XFbJ9 z_z-e3WaBj!>(1@=$lq3AqkeV)3ENzZ`vtjVOMB$2R}}Fe>Q1$~ZpRbfXczftuf=9@ z?AZV{pM&@}2>#<(Ks@^42E9@2okA(HU2ag^^=N1E`p{P=p!j4lF+lUhFAwk50EF4p zoaO1Y#0KF*7VjODh6~PLXP@8>m~o8^#;UuLHKGkKr`dF&et&G^JEu#q=|L?5T{*AC z{@Y*9@_AuV;ct$DfiK4%RXag$ZloZgykfga!&adS!z=nzjwgLh*VtbA8Ce+h>L)b3 zybVird!ktyEnm9?ha8Sdz5tw&Q~h%aV-`qJ#mC{{?cUSV1!9|VEmPug<~{Y7kx4(9 z=r!WB53lds@vw}gR8@)L^dWzYkHw)^RrYS#a^pfsBbQ4AZ59o(NaPsV;Eh2aDvD~m zQ4qe#5f>NdU6a2<9W+~^jm^IJGTBy6)t4Jsj^~~GF8*aog!6+EBiyv81Pj)KXOFgO zJM?K~T`>dQ%ETxt)LWLnH7=?C@b zxr+9-Uby65IQ!d&6L}{mr@e0S-W0WpmtmcAVv7sVwn}(jI$0lRquOUqH>I>?45szE zJPRrO6yg=eQdkeO9T&i4E_r>1C)J#y1W(?JS~>1+XGgsz2Wg4CbRH?Tuda1pp`Q48 z?`f8THy92&I)ijAi(jg^Sla|6bGZx>t77iF+&PzSC$4*VG|vZLUod>g|8(;0hpH0! z$sNSCF*AYc;-s-0`EKcY#OJ1@Df+k?RWNmJ|G20 zyhi=>^20ch`ze>6%ona~Rq1xm@$emyx!E~B$PptwZo@Nnz)_}p2%U7b&msDdeL(i& ztNH`p)3NgXLYPZImv-l77Xyd0G4-2_qoL@CU3?F7KZUu=c-GEo)tKC%`EDxQ+Q<3@ zskt8j0cYG;hAD<0GAutKvCkRaobOl`*X=h72WqeM?#|JfpXQ+nl^1JVANOm}Jm}~K z8NerbcVK+X7z$L^vd#_&IMjmZ!|n)p3cM}5x(#9q{PGt48CsZG z0=ZvIlT9vr*pJijAQ(Uv(uQYzt2u0_Zj1EYxipC9>RWVw3LtGlwPgrZZFM|lMeP?R z%EEY6=@3d|e-==-MChrJU^!Eq z!hYcVa0vBv=r8xX4E{S0S4`M9gx=O&+m?yw09!QAaL`*3f@LME^!r{uAB!2ZV?zL+p-a(ANnQNq+^MB*P1%jA66R+QJ6DsuYl1I}raR z#^B3fY`ed?go{2Q{bKxxz)Mc<@F14uUGb$tvAE}j>3j#F(IeIpsupdnDyoi&LEhmI ztHnPdh11*J4gsl$1VWz&wm_p*r#A^VoTF7B)881F{6|Fz`bW-P9(S8h6`P()$p{}0 z_uzw2;~I9)bzL}Me*k7xxF)9}=1rc;$e7X=_zz^3p9!pzlGW7G7M4%<#03ctuFmyf z)Bdsdo>=Y${zy^VBEW#uF3srE6Ok*H2U@)wzc{~2(E#DQR-#t%cpw?SqX zEZh@tcYs6@*&~mD;0FQ0^eP7IY~la@X#mqE+zB`Yqicxo_du1L(ZS_(eCF=tGMLoA)Y1R+kriz%>o~2){P~mxUAV_! zO#~ob2;#p!az<81Z7>5mAxa@YAtBdU1EAkTR?z4Fgh)bAOKxQ5Z3yPSKlFc}h%A^9 zc#AuTmc14eVCw$}2w2zNj?$KZhtB5Xslor#i7Tx3^)Wj-zci$433piNpsQ zi?tQ)ClQf(E3?h-Vx^9}*xclFYK8@mZ1_o~C)VQgx}n=P6W|PX3>_2iaT^wNGd+^9 zKmW033n!1pq3QR-lGk*_!~|Zp!n8HBr%Wkun`h^xZ~bw3J3D0dyyI{y)rdEfk4@%} zWH;7;gVLhoVt0=%9F_^*PIVN8V2>>RjOKZ`PrC`C;iPMF5<;n;@}_I_K`$n6U(aDb zu6&N8Wyv6HqgCSPfH5`&GURy&Lhq!cvL~`FB{_$LI>`&E2*h&2o(C8iU7bswhBiI@ z7wP;LmZbh~?EzW$q>gvkjeox%Ha8CVu z_(R|5E7lKAK33+{BZdange|nk~lma@$AeqaOVF(&ZP+&%81(I~bTOyRQ~; zNU1c?Vda`Y^Q?g`VoXFpi1X4^6rh_Rq2T;~BsTU*Wu8adY>LX95p{raeoJBD6I9=t zmw;}I{|Fv`Zff-5_<}9_-ItdP+Fh+9w|bRr;ri>Upxd@I<29R5N?*aG5hF+GLRzvyR*T+9TEz* zpbNwaZDDO+j`gsY8mjq?T1DK)w8hL{UoKp3A?)*9Ccxr`lOPN@`VAPoBs2WQI_Z1s zs~Fvu0N%k(h0q)00vjgrE$0$Icl#K8Z`A=0Ke1PRj$HcuXx6Ni0seR6${G?ayfd$r zSeE_y*Wo|j+Fgj8Hj&U4_KLk=e6MXJ_s;dma`V&agU~YdLQZWhX-4`5A}i{|N!VZz zeeSuD*pQK4g0=MK@$m%X8_82nP>3*;@43DTTXM=&KVuc>(GQOqM4(te_hd3nz%#Bc zNv1@gVs5a(E{-;nKD&RYQ5WFEUCf#qlop^&h8>(cfEI^yz8Bi_c`O-a?xY#ydrUd zThEUxu>{g?P=+V=t=7M7(-gBsKQnvUC(5sDUYIX$ z2lugt#z$K_v_1^lug*DnK=sh&z~YB-$8|0o9|L4tAa=HMZ!jedCj3~8`l8F7*g=fw zi#Ss-K9sLDdQmYvFnCzNiYQdQSb^YRR-`;eMp~x3--PK*=syfY^?WOX{D{kP@?^8N z5y$lxe1?w_gYhS|PLPDyx|Q0qHmYg5;T%_fG*Y(*d@1tS`p-<8@bJs-D!JR~OcS;l$CrL2XoMTvzL zHz6EOWOt|%wM|7usI>IGc%}PD=Wh7JC=S@LIFK zV0l5WpS})(pIJ8wUE{jccesN!6>akU#-RN~WKZk}I309NrBv;PpB@FlKq?Ko#qw(OKQ{D3>O_w5Kx%Up8Go&$2;W zmO`(km|ZAd+a>)8DcA3RR{S_XDz@x!arCv!#~itLOo9%GLDyZIuRTvnJ$RGXaFvPW zT}*k;yEq)&1R-e)X#T&P1`lDlO-XP=g1rZ>RVEM@%fnwMHYFDB4iQo$ac}x6hX;-@_ki3>?Em!W(SE8))}!1zbr5K{)#^S62twcYTDE_CjRHLLduQK0 zzXid(e*V%4(Z|tY!WZucO{bf+A6onl&0f1r1bQ&6bL6tprPD1C>auDF`(&=U(t}8r z82|Cpf7l%QES!i~1NDdWi~Xkwp{5PTH*eV&1bi8esZa`bX)>Ed_#{XND6@ZtH{5HQ zZ8mHczMcfJ-&=ARj(!l|z2tk}_H6p?%i?cWqj^{YRbjDZ3+#%Ep!9N^HZhu5AH^f6 zVqP75UzfWja((5_)oHN;aTXQ+?+2gPZgiNaz={`BSQ#nO+Q;<2x#QHA0$-@3q=S3R(u0O-5*j5kbnINO4^5=`IjTab=!gndivWq;$cx_v=`tyG2k zVK?B7>eBZvch*RajF>fZmm;3qSY)TMzSgJZRE8gInG=ZA6A_wO7jI5Ffj^#m;zqO3 z@5{Wp?2x19{ug0y9uL+3_Ya>j7+aK7BV&tF*_UK6hWcblmQsW)Wl#3(%b=pN6Dn&- zLQKjohRBfY%VggTCi@Iy%*^LLeZJTCy1u{P{kX6DpQduooa24Yd7sz&`Fbv0)!(kB z>X^Q}_#ivw#hxuFlUJ^RdAfvS_dJT{zI=M1(9*@P&k9L4R%lj$_ND4UrSeycHa9RP zR~W7?&%YGEA0vO3NZP=Td|X229mkZ|y%E80uk#@e`e5m%FppVL%6uYme`^iD5278E z;M;NFic9FBXy(4KdhS_hmweHSJLsPwDf^*5R{ zZO#V8A2Tut7rW;(g>ADUH<5P0;h~%|>Q#O;|1m+g~ z1ElPI6W>q0SsDh+r%X;UVjGKYi%-;;*J4ihf((^q(^QWb8QuAwcY>n>vtg4n?l1oVux5fAfup>r1|-_^%majUsk zaZuY172#E$a1m|{2ItJe|7(`|x1ts>{Wr9cw$0bI-HWTi4fg#b!^~d;BO)H{Bl=)p zsPc0o1+!}A(t%>oWnO*w4ZoptNFq>fngC*5q~p_7C!UJ6pH4oMZ}0o{tmxVE-bxVB zi;|w2856(?`u-u$)muAHojQB8QNhSIho&IXy0Ne2E{l!cZcMHRGC$#_Mrm|_-;M*e zobOUF1qA^6E^Ss5uNTRgzKAakiemPNRE$WYD>OI%U#kp3|UrU2apMVR$HF6?1lb!rC)CAZ2yRg>A4yS@TkwEq;v`jK=2cEhb{9* zqA4oASNMoIne#Q}iF7o?rff$+YDopMz*M4^eh4a27Vd&(6WJ1jmq(HJ_{xKO zv_Uv<7y2{HM>O~uI*-bm`!-VJY{9)_*A3K`VOW#)NEP~vy7{^zYf);WaeH1=a`2iP|w0k2Jy}*Rz=s> zF9!AvJ>-7Rr>j5$=37seePXwEyQvf-Xq-=kx3u)|nOru&fsb75=Kz*>^&$m_Cp-$r ze4lS11Btqx3@@-)b`2-Afq(ly>Jf!~hhs_0dy*66iJx z@VkHI{0#(>CgQ>AX_M3q1nU+!q1r5*_|7v9#QQeco_8~GpKS2V z!;&+eO~_L32OGbd^|)+C2z%+bBzQ|;HmZx?S0CX}i~^Hrcmv-JCg$30j*$v;XF?~y z>RH^%>J@pf!Axl1UT=vg8r1<%IyzvIa=!jhM9{^#@vA?DOno0mrN2sK2ilcF=JUyW z%`iQ_CrvWkCsIR8hZEJZ(DA0O0(6WHmzm^Q)P<`kbx2rh#q9 zo*Hq;SLaV`)Wxr9rcL(-EAAQl8lbzuRU+yvOYAQflt3P+(Zy;*Dzg1=Xn}55SZ&xZ zXEM_cj5qy{PjEbP0MWZypvzCf|LgN?MfNTO-3l)LKsvvsM%i?%H4I$)`Hz}tLMnYj z7UBbFrmVJSK5yyfU{A*xiN9L7#mfJa^8s(D=mVkz91P~!(V_Zp850en*GE>DubdZ$ z{=EA{?7<99GZ`j08TIHFv`b5pipHy6$LR1ykVU4 zmX*_0D?8l+jx&e1A*mW3y;=p;?0TmnQS80o!@|E%uP8hGElNSR^{RG{uQzfA+AQ2a z6o7Px-KJvZb1oWOJXtt2fR9QK$c#^DK73%XkDS$S=bhIUJ^$-hPmIxi$7HIK)(UcA zQ!9t&^BORcxg+e!ksn|n+zj)XSqFR-{J^$ZSsxIdl^>?O6;n|(Ix#-k9jk5T(4pT9 zi^`_7E9O}Nw|ja4CA{}*Ld9s66DAs!e@Gg8k{YwQ zWu8jem(J^Bt9gQiZm-g=##VElmg?2ZU(+m$7zcuV?w z$X8jf;1AuB*A#v@`f{vH&@jp*ZNAOeJRfD%U6~$}DGAwH2SGIue>Y1;iV@C;7cw3O zG}0-3yaTJ8k1dcgoKFwebs>^#;XDyqC%CP!n}gUI{Al|!>*TU zAJz+1`iObx^?=u4nO8k+ypEy*8PwS_Yd^jvbN#;AsSnLNR(fR=lsYMnD_EqJY8oh{&Nzr3dK{z>P$-CJ^B_MhC`$&QssYIC&juTV;T z-Y|Nffi4R_isEf;okFYjPmXtoIh^NuqT~SYSk_qvRtV6sH$%?@yl7y91{!Ud&e4_L z8BE%D2RVul9$2o39zFfS2tmk?h2JG+KS9n*r7DE@v=D`lmWu8)7*Yhjs2`ccHg$*9 zlLZ0SiLZ&VX0%_Ljm=6lxw9}2#P2PLaK0m5@$!-lR|&v%;qFa& z#F~GdD{sJ}{HyCjtlxN!>re+ie2Od6|4R=L+=}KvDYq-}-F=k%HL>vm0VFRQFWHUX zb`SF*V3h_tXO+JXcp-uz~lf7sa9Q^)C&jagM z>E;*48nS*xCEe|y{5}i)I!Kq^x<99TTmHf^(~sAqUR{RdK0dm96X0}i=CZ8NKGGDm z<$Eq($!=AOt1WEsu=cz}_Tg~c%X}X9F1xth8A$kteLc-s5P(eU6mF)6JY+Rxk4Y+^ z($-E|`U|!A;4P&lw}~G#qKHMp4E+vv*i+3TTU@((yn7e9@()Kit3_zT;S0GZ;?tf3 zOEXl_IgQ;j>r)tI&){dBJ;5-pfK+2-4<`|>27KSLXz{RX%{a3g^zP>EuI$ozIGB zmoD8nmmXHh{=matySax$%qO1b2wnwJu8G`wtgqTz%z`SKapbY*vuUdPk&^OA`{W)d zWh&NsrLwno;=SZkjo=v5hJeI1!iR6U%-Vdumti{orMGQ*o%|&{EyN!k?+FK=f;zCh z<7+81DwXP&7uE;s&*PyVos$h2CJwp;jfi2lGkSc|p{$NOH3%0t8Fp^iGyt>7uru`7 zi;zl5_*wAezPw>g4TXp`^8AgqBWQ-tcHdr6x?dA@En11Ew=zDekDpJt;b_NFiSiAL z@$#Z8JdW ztT*hRAVlutHD;qfzGyEB8|%OjyZL;@%tJxF_=^OCyG6E!F^Aw|b_D?6q*&r4NAC}I zPQhp0S8SNZ(-?|FtWbR7aYQ#bf5nVj(`l$Ozc%jN{7pWrAOdWzS<^wF}dy!Ge z{YzuV{p-3~FbN*a#LB)+F%jl3#cUg>RmwI2$q1_fgudv1C@8&p$U)h0bk!u*IUwFT=$ zkduQ}&=C3?80~Ez$v85Zpz2$$CJx`}TsXfN)IzztbGD`XgvGR1Lw^&~HYakfoXH#X z;|Hv^c95vw=U5S=t=L;~a8kZH$&N(Z=;Kpv{QSB-AlhI0R4Rv9w1Q6$Mzo6*!}iau z%@Qe9&HD2tMf9gBtk|_WW^Ja%mM7}cyacj6Xcrao5Z2_nF}*{94*Dm>fC`qv_k|T- zi{cd|r1CqZ5~oIKlyCLp9v)Z-8`Wd9`!M-3x3%}vs3u>5nrqwIWRwoR zrV2%Gp4$)zFXoGwkB{agt&ve6mg%32!G8kp19z}h3Kj;;e7_A8Ildk{Q=TLFTwk? zQxB?MUoloXHmZa71?w-v>(sdNRYeu0UNLjliyM4M3Z>V?a|qHs?oO!w{#?H$`*0h= zzZbkme~~p`16_4>sjFr(@s>$9i~x&&ExP~DM&G~wb!%0tV84&8U=K#1W8_T+BG3Ob z{rigu_Va!T5bpqzl|os(F-4m8^A~&)_UMhWWh3AOu-hNCoQdHZ=#Ngz{-7DqfpCC= zI(%08u$yn!X31lemxgAdve)pd2X@rB%c;(E(8qBp<+`Hk@tODNe z1FU0O&>vK@u9_M!k~3BpDi~vDLK5Q(Dp1JzMj-4VRgSt+JHL~E$VPqx3-+k2fL#pW z_G$I?T3O|cK;ScDV|MPdsAqKVW`yHOq0e6S=o^}fn}*FhD*=PHl3XX@ff@?hfrq&x zttF2P9XCt1h`xB!W@XEAbUIvJ-kiQs(hv%1C=C;(db~3d_=I}HDraM>l)u=*3aPuT zU&8(1CRDyoNjqnKJd~#4wmsTlHP-aC=c>jz2tTfg& z(NM(9k&B@lB~2Sf<+%52%j+Vyw!oC3R@|Xq?2ntjcuXAAzIWmiE5A2;$V|qMoMz|O z9@AxO_4T+=*QtzvR4 zoi=5j^%|bE4eVS-bAZ7nes-;J?wV#&7Hg8S4{HZ?Q9^?C&?rxjU^-WF@P*;i-6f;0 zWJAzS-D=PL*XgaKRy#J^D_kqz4mbbx@uptwqmw+20vd6?C)@$W%9PZ_aW|E{HU9#) zz4|W(4J%EJmyP+Nr&v$kYwSzvVSstUuKUm5emb3fPJCN12Byu?(i!Ylo}1uW#k8HQ z{7m?%4Eu(ORg_%Jm{_xDo)rCA^3k{4K&-;NPDHRDu zTOf9_L9kLy<)DkX31$UrZ8P$!FfdEE?Svq!48;#3u6Ne)@XobFDh$4HVI7B`-aXn){zokd%hu?3LKywGnFNp z53Z#v$7^Qv(7l8}^tw=BC^DJY8{0-RT3C(&5S-EMQHOzW)ncq16W;~0)OtfFh z_5$MQvX0=f;VV|0AsFij-05La{}s_nlP9MtqmdbByf0^e)Uf7K5MAPJVJYwa-JN{9 zLj0CLv>NU=*n8Iq~wR+yA>?{44|H9XP&PZ+BowXakpl^08k!$uZJDlX=aJk#2r|oPB8aH z=Rq<~dBOYXX3$w3g03R;W5k&%0LH!C?+N-AZ2vWYu1f32Y^412pHh1k|Lg+Wt;hK5 z(OEG!TmdG*WF5=cMz@0;B^$lXBF1LXK1c~FkNwO?Bezu%IIydE=vep0`XsteQU%Z7rywgXtJUFF{6%5ZjW z)foOxR{fR?1;diJ89X}61;F`8gD!q!ZCLL#JVMRYR{ANo+Px%xuW!fDs&j?_(EdAG z+q9uZ-X>l6JgyL$tIG@XM6vt&8UaD5!jE#V(p)vY zwdeD8@~~BY)r0*FsL9F6YGz)M!xeZHZMw@w;|y3mkEU_MZJu7w4LK{Nc@RK@OyiDy z4X}Br9NP5mk)d!wO6)QuE-D;j|*hlvs!0n~#xUJW}gsQ_|gP#A^@J1~VN z=D<78PhWcHKo(Z_XTE@l{#x6K-=R_3eJHyt0%mQ@vbn8Ru!E@)dbQ>q6|LbO5P%PQAdh@g-3+X{T3Gbsloz>4*IZL3L)SHfHoZ3h57Pl!dOp`C`nYFn!3o^667-LnpAQ ztI&+#RlvWUhZ15Q62pZU5uqC6TNEvc6OnE0?-qsLjk{S+7kO!oQ}js}?4C98^k;e0 zYbzaYjb@#F=<0FR~~M zi`X=JC%ts#=*13e#rO)C6uJ?vnT-&x<;v$-66Pe^>}@bq^5{Z{VWqH}S;OVs7l%X8 zy+f;fYvMBovZJhrU-3LB@j2;h#%3^P%%KTQzI~`A%IV!QsISef-x%*T?zNU0T4J`g zpR;>yUcXMpxsx#gv_B{950Alu}vm)_l^kJdbz(U|%5gB{>Ml07Sgxzjt#9o1?$f%fM~MweKX4A~}9?O2kJi z_X;jVhTH`GH0k1OUz`e*40!H-5}4I7)!|oS!QYSCIrKG~UIMUIPb283#)%dQLflmW z;!ub_7q<%#4M;??P+(#rHd|)%?6Gr3$F0@uh(|Q1(U*@^F>SRJ2F)Ei^9xmpiEs}L&jeADfGfswMo6WB-p|-PSyT#U<@AT92I)P)_ z=OdxnTT*LBW<%Fh*S}4jX0djFOE@?^g`U=NI%uxz@ormNh{@;cK?|=c+e>T%(3%C3oi>^mKXL;v&v2v&Xsa!IqLS%6i;rx-|R**7n2Ct-u78-lIV;R)ub|=Qv6Nx)6kY z`~su}6-?`gUsbGb2;V(uG|zOJ^-foyMi@mLuP-B%)r2#{`99D&w+OnMp6L%hee^hW z%;9Oz<}sGfp7W5XvCEz?DG5!2=5k%r-e676+Ul+~6N8HXael6(vzmglF7s!|B^{;C zZ`Emrdn~MRcSSvEw}>t%mT(g`saqYcI(a992^NbnR;YlG(%fp37n%YafrFSPT7gdE8$7G#F&4I8>FGebhcXeUo(n7fu-@5|=*R^A|>f`}qb z?MWosZzoQTMwzV7t2I+&5rJpR%IXlGM03Lx<0Fuu?~&@UGLJ|}G}aR%7ao##SsVVP zq#(ISLMm^EK++c3*}UAo+jL~)R9ziVlJ=-lbwFRRRY4H?1}>IOgdIz8MV88wXyok? zVqDhD;7zw1#NT;oY2uTA{^s<}88Eci=5*}*T)Lir8F@P*$%*K`XK3;UGm~R?jEB@5dGmB+rfc`77Xw2{PIPEu+JZwZDAyt2%Okmmp5DCFSU<~@enKn zCm~CxJwvgihZ&3$*>u(|t@MJRM)?b;;<#|eWLDV!SR97DM1M*Xz$LDi!Ba;@O1G zS+1v=_?Oq$xIY|qb0IcHZsFqX;97iLv!PucA}E;&p#Y9k`a?b{}8X*XwQ$ULP+0no#<3oE4I2VyvJ{ z5E(6E1Td@Y0_PCJ8?-mfjqQ0l^I`KbARI?;0q+$ z0E*LG0F2f0`NHa`kf7>w-mygg4z8`Vo{%VUiCp9!-WbsIYGbkwg{dyw(VfBYF0QI= z^n@hlbOU~eUB}WpCfPoIhECp`-v>n;nl-uBey$Q zk90^)4;HBr=`lGttjRXCHfxS`kBhrn-xLYVL+NR0x#uSiFopy}^o|v1eNaT#YpBTb zMR#RWBsM$fijU%#o}j?V>WXIx!;1z9w54VRmReOcbm%+%YR+DVin*lnLdH`$+kK6A zBaGbkv6(BM*I`NB;u(6$AP?Zo7m!$@E%DDeodxn?(w`Bf$3Sx=*&K7`1+E$;$S-!y za3t85$Bd07(HUK!6zwDsZ9QHbBl&82)gYg%5`rxRH4=H?l%JlzIBtMF2w70*Deq zbv>bAqzaZ~q~TUzf-Qv(l10ZXzMl*)PPDGGtax(9qNBNa>@tsiWY9XK$Mthn6$PxO zVS(?*qL!LusjYJ+{N3WY+rIH_JJJ`;Ws_bKizO5B*(?U942`qMHW6SEFymW~Nvc}Y zU|gAKKALvWE_%&=5NgvT9rP;EXt6!~{oTYyg>VC{wymq34?!sM>!C)8d8wzeXZTP* z%h?qKx3#WEwP?K*1Rm~oA7?g2G~qBMci)tS`EALrNFSPb6Evf*tJo7H)nsy=uyUnx z&^T`x{FWVPTHYM?8IkAES~HJ=_N&d%Z6z31ZC>kJTo{O)N@EQCq4C2P7?;iO5qvA3 z#PBXj?e7ZCy@dadQ_z3kg#Q~F^#AoU$Uu-^TTK(9-n#UnlRuKpK)9u)1%?RNNGdxC z8X_|D-GhqoAvMnf81)w61yG+3D7+mx!~1frg><#+^CYevOm=37sae?zpM{!_TMfyc za6J0eq5pOiexG4d4MzUQ1&Xg>o;)bPEUIXhbMQ|s@H zwSXN8p+jeL$I>ga`z2!rmK8r=t`QBB0%jZ}oij%q7*a&yBJG)Xx%f&K*1dte5<>mW`T|tw`6lsQ8+qk|KC0V_RDM&syx4expmkfKK)<0 zK`pDi1ImfKev#qGJ*_Xopn7XykB?$@)GOLfW8)$2^}(K%>(^$w+>oxa5F%Q4LgcYM z5yh*ghSyByC?F{^)BXk&wT`u|tT$$vhZg<~c%d##K3uJVZZ?>GDI6@x=;HpJ)!XRVBy*c^5e| zpZ2dKph-<{m?d-ao_e9*+HhyMSk`Po@^qzi)v6nlJ3IlF(NRE ze*+~iGn#}X*TMtc+zP+~j4AjD&0QtZpQ;{RXf2WW5@n2OMS|NKechY3Gg1W=|Dvn! z&)v@-yAS3~6-`m9a9!7nW@GY^zd$)6KOAwa10CAuvEZxL2^(=3+{P?Pef#wUD{o#l zvf5d;w_R0FD$8UZizvvWJYe@WbO3VM*LC}|DP##vgnXIaXF8^9aD<_bqge1WzgK-} z$_STtw+x|qJ~Nwb!U7`Jop7g)7Kys+shj}68WN8S!G8R{yTb6q&G`Sh7)1^BINNoN z8h2Y}BB46oY;*dyuc1mERI&mI9a?ylSc}e8G$bjmc|gK|SY(9iZ}+iIQ7fkv5@ zhekmCMtNie!W~f@{0U~Xz=NPqMl9o{vLB)Yl0M>TCUiWr_}hD`;!Y^n)5oQi7fS(T~5g zW-s9szNHAA(37~!&r)(}W=5g2izm_%{W}fY<#+y>(&4nIvyxG1$)UMwuq9;A;4c<^ zoRiS_%~V4|{L7!QOFlh)Ca`1+s2{)5iER5m*iS9grUun{Ro_9nN;2J;VD?Dp*l#^u z_)7eO|98XZ;b@TRX@{9=9-?Z@x?=?Hza3dv@Z|EDWdbcp5gS-)Yj82~O+- zxbTr`ToEPwI=MaTK(%av#momT1R+LsNK*DXI@WTt3CTNl!B?c|xKcN)T~6=7xu9^t zfplY6`bENE(cIqJlAk)R`NF64a9+YfI{|SF~Q8= z?9V=%P%e?=?eEm8e*)P7LwNi2D8a4*G>5sO-xRM|JC5!~4IIy*pPfa7QvX*4(0He6TX&FnMFXJX;x4u~-y^icEu&0;Y`Dr8GE=673Q zjY9Su($Q2m2-)0Qv8>ZPp2~a0XU^I#m5!d-k0MNren_0j@7(?+$35e#k)v3(ll`2OOz`EpbIbSqCUYc1LN3 z6mVjEhbKVN@en;NHJI(9<7h%~iMMaKJj1TWE`#R&n<+w-`cQeq7VrI+XUfY~)pqf8 z4so?rxIm}5{djeltXm=3+%|x0+!=A) zy6O1AZt#%9>q}p}IS>ZTJqj8wShXIMLAPCX1lVvT(Ut=)Yd*SOe?TB%J1;DTCZNB> zRQ3m_{JiZ5srp5fZUDIkz>Fi`i-oP0H3FSGn8ui-;~5QCcipfzt5$GnQ-qb_3>ma@ z-r=vEbra7mA*m(*q6~#_RiDzoH0aeYTT(?SatCksA-%Y#1`*z{_4v<^i8f~>Ed-}|Vb*pkn7qQ;@Ht#$b~x(qnm^c4~J zDwfXE$HLu}9~ivtQf_z>I5gRXc$KHt9ti$*u^+DdL+_2R9!Z-^9xHHI&KaOUx&g)V za`@f%DX|SlwGZ=~NG{M`|GRJh&v8}AaTl9#(W6@AcmNMeJqslz@kR88y#G>UJ;_67>F$G<0Pg^?qUxKg8H6MCz`J-AtJpZ9hTVxvxyFPo) z-tXSP!j%&jEsn0azLoPmXxB!vMnyu1NMbfrTl8qqm#gZ!SU+d$z;ubTVhavZMRu15 zliDMZ&w-H6Q=8?jBC!HoU7tZ?)SiNG0eq#ayCX4|SW{0eq_xk-GnDj%O>}Z!y!)MX zfWx*C+q!QQos%q2-~D5{1*3|-o#JP_UZWVD;eIA(sL`HKS$%PRbi4evFje-&LMqn> zDfU_?nM57jIlq+~rDwNS${Wi9vZ}}jYkov*B8E$re!Q;6qxpDeFYA$7 z2Q^P5Yck(tM1pmHX>Y*}voQ=?wlTD)6!zox`~A$+eH%CG2DLKmMU125`*lviJs63p zSM^ZTaGcDn5*e7lP03*7Vxla)81C35W#uG0<2a@O4`((-7C#Uv?lHRmB`*Zn-FP&` zX>xouWxQdH$3$q@1deXggGGA#C8FHb(-uJH`aZ_)*}_r~uGGwTdV#ry65bduOj*u5 zuEk#ybEYa0)B#S`5hWG(cyt7L^5eX9Z!VTVC-}&k5#3a&ivSpJcu$B3@jNG$0Pk^d6{~lTjoV@fdRT|gDFIK2K9Y;|F(?C8AA*_$5}LoP?iR*PTaMp4KP=m{0TNFc zmVaUgtKRhZiO8bs?cOkPnVJy@P?}T^tc6K18zYKnDO9j1s)Ve99C3UFk!e{Y8cShj zo5Eg9Z+ZVhaV}u8``C=ubuU&UnG)035YEMfjV>YDDfiIgwL6;pDi3&dZs_#RKIgh1 z)b@JN;o^=@^L`Sw52Vn1Gc4QGeWQlIh7|4Scf!8p1v7c_Y*SEDTocbm zle?5%az2;Os;z#T?-QEn;*Ke+lAZ2opf^6(FZU}ZBoaBgKH^@<6$EeRt3&bCkwNZM z;m-az8Cw5{iHfbf&x`T_xll>CL6zq1yb~_HDD@=lgQLx>D76)kPo?)N_7l1b+k|3A z(wrkv$LBZr-mDB_?*q}`JXYoWBU4d%^Gy^wsv8KQ?3NZ(KTdu>t#*bN=$vQ#4$Xx{ z#GZ*fOFvg%4W=L%Ah`wW;-l@HCq#JHur;iFS#G}#K&P1oIjP{ns?@Qd*S{XfI3$Zn zCjWe6sUtzvv2R6WLn{DH%)HFlm6lIWl`0dwp_%*PrM^)Txl*&*&8G8Pe z0?*USfbsUWG`|3=%pDgYJvLvR?V3rNDlCC+NDbW_Xf+ zJ8)&1P8<};yZT=?%>T&&{x6xCo|G4!4mNUy|ESQ|Naw<8dW*LA=q7gC+TiM_3beHm zdq^S>B>x6vXgi>#NZBezf}xhr#vFmxG5B^=0O30#dA*`t5@6ezIkLyV(1t(?Lnspm zI)z*>ozLY1OFiQkfUup`qh&p+Gw9f2Jq;>(KrBNDZ zFn)<|2dugNd{G(bFY<|k1`Sng{~VUS4*H<{`IC)F=*_2z?&P)gu?XJ|klIrFBbq>0 zHG+lvADfkb?fcKiKj*@?=Jke-X%zr)W6cXhSf9vQkgf5b8~?KvcyN?HT`_L~9D4EL z*n60xNnr9Bb3~*FMC3_*EdL%9yEVTx8WFA8u@c(w1~Xf zKg0a5u>te?V7_r)Z>&6`av+KW49Q>X6JQpzF z#nU?U??D0wxLujXMhHRw4%s23IR{5E`s;*;`JTjBJxZ1%U||gDZuNAxv}B z543Qni~GsOFS@FHFH;SGXeHl*#bsX3F~w{IuSIMF$-NRgx#Co?Qtz@XP+R`uoGz#C zIce3!h#G?`r}v$s4VL~AUe7*d&$0L#%KzY5 z-&k3-F)_O355vlYR8_w|L*Hl!^t$!1{7%glIg?ez{9XK`%5v>{Z5gcMEv>2?Exnrd zK(6p)1IgpT?sy}p!T7Q|AtzTDE+M==@BVImeI&C9TV3p4m^u-(ZLQGcQF^vnP-emf z=86Em+Vi*!KWpJzT_X|98&%g98woidEokFctS5~Ey>8ZoTy!kY$zTYr31AKj@2Oig zutO!KmHj)#pQ;H0r4ABMc#BH7&DwCVM|fp*)_6~Ey%gg?VZfMGSxCdm`;9>7oUDEo zBhV|Pt>R7EZ+p1`PO)fC*7Z)|kVv28OWT&NLBU$}QZ6Lo;w1XpvU}XwcWdy*lejFa zrUMPjcf$o`!|4~PS;v2C@~F-y2PEq9PUd@b3Ql(Md?tVAK6E-XQB%LYGS2;nxJT#E>Xq= zHoQ>f)w71u#O{RC_Mvk>jAeDtxt5isua{?Ag;-6M`KglKKtW@bEKB4OZ61@G%iXnD z?kB8~YHYfL%~ImwR>rho59%1>M8WWF_c%An)F-K4MyesWFT-PuW7Q1<%3C*nEzCli zn_;d!YonUNzZU$`YQ!RMzs6Xkl0I7(F+ez~=&qJ*k;}V>HU3SFIfN_W9^pql643%{ zs`K~fWqbv7_CLcF;FA1?2z$>3K;bN{5@(dPLg<+c{Ddj1Ec3ht8rh1ZTDc9=%RFS{ zze|hLxtAmE<`gP+DRfG>j&X>4tD!h4E>qp4vV-Y<53G?KUPR_?&c21mgE1rPzy@=3 z^F5F1>cFxH@e4gxQ^06v08c~G&)f5hfa4kupb`B6${H=B@x{6fcX8=pw<>oGMw>KH z-M^RBIOcY718H~b3(0BYbia+7(46`e`P8i_ajB$aWqt!sgVi8pr|9_@Ua&(M@qBDw zE3WIuq}d3_Pb#aV)Eg55_in9EuCM2;XVeUIYB{>K&XG#f?c_RN9(^nwsn3=b9fz0n z0OMLAVbvr@C)Kgo779-Xb6!azH7j{|%%fBq}8Pqb0HC+*>YhRhXv_3n)m<#R^ z5_+oE1G9ZRlg!IE=Ysd*r+&HQE^DRQDlYWOTIx~Xrxgma7X@|$rEd4wEZS@BJGc7` z<%T5c2BA$KPYYycbRz6C!0cswLYTk&vojf%+Ff7cgBDI8=OdMW@$iE9 zh+RyCbnWKiR$a~0j{S~S2iDyDB&;nGZ~GWJS(hamB#PA8BP4zgrR%cVx^YV}I5LI<|HP%|i?>cyZVWI&EbGsU7T*48=_GqhlE?l_2%ZacC}SxKA#~qZmXe9 zOMG+8jc`O=`$#a?O~hI5wA^MOj){v47@S4p9;>-NnLL_(nn#~36Z+{lp z7T`I~qwjnuEgK5ff`NeO=^)>=cTKq-U%$q;9clOF?1YIOO_w}E5F|>Z9me8j%j%`7 zUrUem8qy$y37gKvtF_JBFBJh6v%sH$Y!Wcn&IiWDEMx8(B@~!;4QJaUAIY6 zP1iz5SuCD$b=>6nPw6%NxugQNHdhJOyT(iB^OY5)^6x-ytE+D`HQ9oYFjIAZ#j$en zqU;5a>EUV5xbWO{hRp_Ujk}mtjOF4PCBr}|O}W7ity=iZap1{s&98fQ3#U_CyZl$G z8#l0Yu~#+2}RKOz@feNg@d@mw);z7W-ZUQpqjVgCpZ^ zC82IE1JeP!50%>$wQ`}Xg13@u>+X|v0kve;Pmw8g zhs6;o1g#TVFDU%pg4^OqA|oMr9fix7|FDd_YyQr;1Z)JhJ1_SYgI`>PteNOEtQ9WAW@P4ajbK$ zC@hD)nxf}S4)G@O8ZQHT}W?e8)sEK)dX8eeMNybwytK9% znN(RD6y5Em58$Y9W0y>z!g;{4(s89Z+p12Y<_XX_HDz_oLuPk3sm&wi-#2qa|ZA$v>7v5!~zn9e-mw!1Ef}P;)2`#>Q*5 zDCO-RE&&HTm<1ae;sS8mbdOKCMXUR=+RpSZm;irx6lOMor0-DFs80_*0=!3adoRJ6 z2;1TyRfv5G)d4rtrA!|pz}NACi^6yB-@q6|Ok#8(yZjAWT-)s=ZJ_M^uGk6-xJ|yF zj;CZXf`2KY52W))(=S8L+1TdYrh`T0U}Z|C`E((C`YsrfOIyI4JmLT(ZGQRLh(yzG zfO}fm)je-42v*^eTG}vxPdgBIvRJ=8UgTxDM@$T1+z}i65~+RIb7lukIf^($>~stj zRm13^CEnK(eg*81lfr;d-B))(mJ%5}V+tx{kU_8oB>zyqjY(T#X&2huC*naa&eA)P zkVr23hVVW{aL52yVPY{C7;apA;#kM63{^K_dL=!!`j^$rgZLcoYhbK`pB8oip*E^1 z;j!^|DQk`>yK{%Jad8wT?-oD|^ggxnp+3eS%{D~^C~eZLkHQcEq36#uWkb?i^h&Mm zjo#z~7v3NMr|5*bJ#BHP7&yQ=3=Towr#o`3cTHJ4m~};c<8=rb@ikZr_0xYL)PDo_ zsGsLF>?W|miqZuZ%(`MQbw~7Xz;;hO$qm|D?B!hhwp+5hR6K*IlEw0G+2jMM<-Gnt1k+uZQJQro?7PDTHuz{})R z=Zkk400|hhzzn$xlXL;ofc@_A_Y67b18-`~?|q+tW}5<}Od?AG&klcWyFKoR(;HLI z?5y=NB$b)&rV5gYi)!&2h+eLn6OeSd=1FKP+V|S&bGH+JWigCCY#L{VnA}jhuC1ZR zfY6A+@49b9y~{}x|8V-P&Cj#^NG0}T8!hf?dZuX;MkD6(U=g+2a1;+!V|=cQ)se~W zxQ|n}Jb#nGc6B_MHcHl#7X>&a+Q78VE1#Fjr`rAb--;%?Z6)w^wlNd1Kyhft3dAuu za(XJlZcc`+x6A#wc#~Q41DN1(go;L}1}hZylF<)yzX}w(3&DCa5m44TU;u7Yaf_Aq??6TAqB z6xaxEQMJ$GiJ8wn>%dqR52)2Vgy{1DiMkPNz+8mOtX`42729=f#Dwc}v#n$>Y9Ld7 zxi7j2?1?`-KkLn9v$V@=sn6{)?mTOoTzM-av4xWlp%iG3NMZrWdk^tHjK5eHWmQ_~ z@5emuO7aOl^+3Brl+$R!kr)XC!C{R8R0qpGQ+?~7xuKVS&f7H||A^O9xszbm6dT<( z2U*_LVy>5Z_fY>dj$!%k{Y3VYd*!d2*iU9^cl7r0=R@C9_cx}S$Gx9fN2-rs{MycN zHTp$|XmrK%2|js8v70afO%r~EmYU=DsuvTCQbD|c@91WGM3``f$Q|v|>c`z({pYIB z)j>O^(xaO@WFdlwdB|uKy%acN_GY4rm<|?s-o;;Rv}L&`%D#BhEb+`)gyw^=KwxGq z>zHcr>fX#pgR{li7P2iLbDX{eJ;6pG5tnrIx@I8b)2sb6cp=rf1-WxF!anI{q5Jxs z<3^fZFvm;!qVqt60AZTVeOzPsS5TSPz4;U!9>n#d!A|~*D$C6?Tk_h%l?O^x=@$CA zdY9pxrvMWs!~_9RIe+ij<6UTha<85>aPXjg_IdPUiu3EF{%j`x_`W9CkC~L<``vyy zysN%3LlF2J>W9O#J$vrj+QFzU^c|Znyh)bh^-|y&n|_CowsMn}5)Wj5VT066qH5z=pe0E03n`Qn>S7L zGPL}vWl(=|Lt^&)p~c*aje1%3U03>fKSq zdhS$vtQl{drM|vLZi6R=fZxz8+kmfDZZMP2d8EHq%s9>A>&3}6IiJc0s_lwk$C3Z+ zoboHx*F#R`p<9@bYe^PIavp-o?GJWuSx=Ar20oLc#_@RrwOXs52l7}CfrgQ{tamaD zA)WYrqOVXwcXGmP$!Ro?>oO*&_~YXpoJT~X{fGA#qZAGdqIybFyXl-*r&+FX?5;oc z{OkqRjrm>9b|2xtzoXJ0`d28*J|Gem+DWYFWwuo;mLWomyD-#c_v#})T}R5 zzZ}+7BS)IqIZT#M9a-9-(#oi?=9Qjj&kNd>MG~eojIYqThUIQdDP#CT)LO1u+m_)k z+l*XoE#S$P^X08`~f2B<;0=V zhQRVk)yQODw051IkI*|cs}!Kau2t?AWS%^MKt%9=4N){Gr#?9+!(FcQH6yy*{eF^? zqmQar6Lt#hY@gPYq26e=uxEa@*gAG)IyL&2?WMmi`PIjCze(*BkHuckg2q!hX0$jx zM)n?VI-YkZd@9ah!~(JLfr_*sYzn~2pqgDQhd?C!<>Zii0nPuWh&IQ3Fcvt)V9NjKmD%hBj9Gz zS^#h|FLb&*o;mFzmE6$qhdwc|-nm54ux3^D9yox1s3Oa$?Pm&n1>_z=v&6tUI_zwF z-l;K>r*J#tt9k8mW}5kke@vLP=QCc(xhr6;LLvcq`P*my_1H|sglm~IGkwg)t;U?o z{UN0n$w8qqKD^Quhq*5_RN0-V@Om}&yiZZcw)zRlF8!6%Nzfj_nc*&%Jk=CQH$o@r zGw(mJ(30A0>R^n1x1y}(XDPwfR%2&nSB$ek<4OYO>Dvn{4^O4&pN|xnJgjs`u;bOS z7h%3EkbUmwC2<{Fms0?LTa;(4M6m#HK_*Fu4ln|;Ao7ihL+_(b~OqzrHlsBd~74vr5D_JHLQ%F&i7wPjovLDEL#Rw%Z6~7 zy|tZ<($I?R%p5K$uH^H+vxLbgj zYxpFcyJ1)L`AnLGenj=sdD0%ddHBT3QhB8{PnOeU>*U3|$ivQx8Z{Qi+V*BFqr0k5 zcW7ee5B$3J83jLvOg%mrEJaJv70Lm+Lm%yxN@H4VYGbB_O*^j7K-!G7jN0=W01$m( z0=qwxZV=w%HzNJ9(M&HI9ndzLNyrdZ;aBEAr4VG)E-UNBlS2s4*#*&nTez|)*Ok1O zD1It4L8m-*-um<#QLRYzrqw)u(ZnyjqJ?UuCO-u}=E6`?DYAS0>m{kXgS54%(3kB5 zF^gO3G?l=ztvdZGid|sUhs)UaT5c#9HC)F+)g;d6-UE&9XU7nlE*!D1o8&uS9375I z*^+7w>uZhn*0lB9gse_^^A*gL!{Pxca+O?Om02rtyy#oP$Il3m9HHUyjRtPUAEXa% z?$EZ!5hqv8!lE+AC|ST*7qgydc&zrRv|AZx0Ghyz#nW7mGp%=4WcYN{*W0F4R0x9X zi3$q7$?+a%dUA^Pqe_hBP}y^?ryiMpnbaUhmDs5T*i7gB9foQ1hAsv1S~wYB+V_r` zvcII#4l3>{e1h|WGm_tyjV!#kF~Nx*=hhkgm?x~UMl5LG z5<`Q{9JRn&{kbQ%bK(_@MfZ00=uX*;eXv5ceV-F*VtyKpX@vbi5IwIn9&PhnFi=c+ z2lQ&Kf!w3)RZsR>(fS6ZOEFYf+BG|lN-TH=|NK_4M z*XUFsPYZv%0LeVA0wNH4RWprMI_3kL!7hnczI)FHLF<%uK*?g;0ZgDN2H)=t$1?$L znkgY~LVNhfkVLQ+-i_*UE!}=dtSrtzvE$8*uwOfDOE9ygAd4%smI@6Q`qcH3?r!3f zliJBIa@M3J!9;mJ2t<>DF<6{=v-?RZ1RwDS+$k!eg05MDBHK%lYxO6KUb)m7SlM5- zYkDf~#E*qW(~T}bMVHQ0DMc$TJIuEn0fj- z8D9$`3MHFiVvTBjjCcRrwhG+yLbU&*%Y}9X?*)(UKM^ti_~C!=FgUK|&~oSoc?V-t}$~BIyIL6!2P({J4g{lMvsC!cLQPrnJL| z&*{7?jiS!+KiaHb!Tqj2PF{}2YB|`yFFAH)?qbPbv6)IIjXvC3+rBkbw7fh){;@#) z=3L`8_!jkf_iN3wJ_8-Fn8kO{ooV#ed86ef?V5!A2vYrDR;HEeBr8_Q+}7{ z*m+YqyE;-)Bb_Exxh2@n#TnX%k}27!93Un=qVste&gs2WaOS=X;^w&KwQe0)o4b$1 z6x_Py`)Ii+SUvQ*Nis6oP|a#zI8vrurs)YZ)hElTMp-x(HCW`PPij0vy;4F=8kY5x z$qS5%KAxbS;RwaUG@=6vn=j#s1M_7ryy04ydlJNHdvPc+(`E5jd{v zwCba$+yeyE!kP?itOv$JigJTFDqawRT=tD67FYm*V2*|8`)^_qkOYZU%G~?49|F=J zpLwQjp@hO1AdCkL2L3MrvK;(j&m-J5`oMwud&UCIyJ?J~5rCO^iYSsnKrCAn;8GiC zge*6Ev>&GEpR+Z0p`br zc36TiVOJ04UWJFsa(r8G>b(XXR8iJY)_jiSR=y0GrL`q%z6shzvzhm&>8Y&b3BM=f z)ccst|E)9pe~G@1nX#9F+H5ji zotGe5__T2i))s!lFmPvZUEA~peNTiOyRQTmrz8r;iKyOwPb#NH*}>JfwS{%RsxQ5B zVP)gh&=XOLVpirOih-;pAN6`mxe8o(H5n}V&oXicq;SY1HRxUd1EaHx_ z?P81v!lA?|bZ$-r`cM`ev+WLG*)|bDn-OgJaidY;(J^atA^1PT*wZ63aT=P2_}qA*+|{y;AWc8w{>*yA1&2 zQEJy3Hze;x1aAsGiP2lE=@5@@crJk%%G4*}KLi`Q`luemjytsR2DO1!Zp`CR-td$| z$c%7o@RVp^&bUoQgZ8`=6+ahP?LNLTUw(BixKSU5Q^8{FVH$s4dPhwYyXI60e?9fr zFnN4@Zfzm4c;HwKOt^kKGgKX9l=c64bGoUIRLP53zXsHiHZ|B1IK4_W@={aBHj%y^ zRZ(13i}652euA$nk#0k=sDmDGrXpgG7LVM3e?9}ixK)E;QqtgIxKVENB_7|RsXA1z zlI$)16+YTQTE|h=Z{h;?vQMlCt2JWk4s08p^y`B3h$<4rkUe$E;;TQ$FF<6Yijq=s zy5Qn01A^H>5P%6(FVp!zjIo@>=Ci`DGlrL+oY36#sv#7R5{x%5+kvrn5HgB`vd;hW zVPS4VKk_)bYnw{~3gssb#!vWz#cfF;pXUs&{^Xr{csNtIF*cL2WfV?OA+13cr%xQ- zA?#lqmvNqAxtBEM=EEv+K3=a=!=Ic10s|Qa*Ibi-UHn-ln5b(pk7{K{bs;2+vwidI zk3KO=i>fgC=m=Z*zlIf2S7C9h0!IscE!zV)so1uqyL24B^p(Dj?JJ#wO`! zq9&DcELR+PNZd!B2;T~eUe-QoOYakwseL+|L|;g0V^z?)?W39r%NF*JQ7y~_meDfT zV>EuWPF@U18?BldRp||Pq8xg#4V|^Jezd&47eL3%w9fJu>f5e8reCEFbXwl`-#13_=g9hJMbvYn3&w5A1HMdN_fQ%{l1qPNvk+QaTrmf^U7pw z*Y|3PuuI5*14%jA>5;abC9}LTduy95^&KkFMr&(Z&URCbi)5c&YC-y0TRHJi#p}F_ z7~^H6?h2UC2&zgdbEIi5+oLa2`X_D^FSLg5iVe}>?_s%0?eLSAbl_}?40IwYQ9;$QhOC=&CNi* zINC1gqzvLXAVZmlz`c!xRWZB|}0Rf}Iu6ehG!yrvFGfvQ#z+AoL<#Jo~m-o~< zh|-CEKE7K>f{-5Lj<{27XPmW%>lf6!$;jWpY}2Y2NB>K+a=%r_EKp=%WROP|sKq_- zV_rSMAr(}$7gaPFWd|0$JkPt?Jy!RK?A4e)NslVt^=Q9x@u0q!g(`=WWRpf<$|lgd{zXPk>!q%@fUw>p1>nxYgNp++~1!TY(-E$ zqPx9*{zwscH`MZ6&fS#s0c=qtTn;x+dtZN_0Acjt9dCV!p4V13pweSU2{+#3g$a369=d%uX1FB4r{hZtRZ=-m!OZsLR+DVn_;Yu_rD z!y{@VzTN(=Y(Y4UV-_OCGramR0PEy;fEbdmjjdk*K zDmMutpJIU%K(9iuuu;V#%HXGaI#=>8v-r0{VB^#w(12tGIv^wCFT7Uftw3|_- za-E9!A-M4VGX2i?ovLiMTU{PHKsZ;gX^#*;8R=FVewpt0wHfU$;WZ9Ub63_5)81Z# z!D1vo{P)%C|M(qm#qvT`-AgzBMKNXp|GHRkELuBlud?|qsILe;USIu|X^Y6nzNWELb~TT7Ai^jryL%OQ03hgRTSw*BFexE?!siBjYoO>Q0nw zeDT!7z))N3)E>2I51GSV`WwpC=U*dq!3_D)DVBHcMyyceJVV&=zBfZiznlL0*VkR} zc9hA$LmH|7@aWM!-oF+)@b;E)HI1h1ThWsB%5bg(}Ev)$z#bvdi>ca15< z80DlUr(X%W!*Ls~N*-PoyMUa21zE8>e>ja^@4)yq=Gh z8DIj87KzR}{d5@LL-X9V*e9LdtIYh%fV^6{TdndkFEH+x}>Ng(uu!KnXf?BwiA6&E3lxK!9_x>!0Fd z-r~oecOYyv0Gz8TJeIVkQ4KZ-xPLOa?dt^unY0MkCoF3pH*h(T^ex*a^J?_rb5Oy| zW#{zsFn_kG2jv%xUQ=@dw~*n&Z{=2K(G$(6a!&atwjwQBbPNa>GM`)BC$6YQf|#2T zFmEA&HcQ6ilvmU!ZMvdC>tNmsxH-4~$mQ+*10mf9(LVhx&2;fEcrD+_%)>s|cZWwb z#U)7wmcxx4XDMP@nvfq->ThfZmORGy<}E#;xpnWZz79$S#%3D zC5x+e%`073aLQa{875GrB+ zxAXh21_5g=pGbK7;n4+#-2Y{V1vz#^pu*4o!}8?=Tii3?4&WjX9|T@5Q(3I|^@RK^ zE=OwM7a>d2^71>$>ZM=gGw(&yh$ydc%k*@@X{cQ5oPB23+Xc8DXkc?J2HbZK58GLW zUrsuVG;i>N-RQT!N6o4Od5`=($X9Ydte{Era5Eoc))}9g$G+5ji8V>P;#A`xRL?$nh{i&_F`MvH zK<0+-;Oe(08*M9B+eH`|fP;Ek014Hy3HBW%#8-2jjQv#_l3|u>;gjr_vFk;fTq>&d zCVD!IT&z2fm9%IX$TQBA_n5VI8=18KC_6RD)v`WLnpt(=tiJ(2Rc|23H17g10jI^C z`1qjJ5_todA&1ZFai^4VXIqRabyYLoAk*I|)>?Q@n-@*h$+hg~#Z$vnG*v%63AI#o zw;r*?W3h`zaHR#Q4-GY10k)EwhbSTmH<)3$Q?oxtbJ*~olZdTXoPFhhelexjd4m1f z!(%ZeU5WX3U+lu#0W$vtpi#>;9z0vwn66Qh%z5n!KEGMop+EII$+_S=>vbd$KO_bxAA`SGO@q4 zwZN3~?{FUj-p9qq1lgYgeXy@q5qGGeS3Y|f3sqlZFZ-JKvN=N^@!TniJH^YA$w|fN zDmPauUGWbW6BFa1p~|8VC-_LG&0!Kz%8L?~cZv4_f7u%Do{i4;<1qr%IVCzjg*P2VE{6 z6J_u-76&vRgo@8+rakr&6}<8vHx_h630|ACh!8)xJUyZH=8t@**`u__H4p!R;Oo~z zh3aLoEd8@*^FLh(4gXnejMl7pwN4_}MZqeaNPT|(b`boydl|Btd4a0t&Y5!l=ZB>J z8;e)Z-ERXS0@p*ZQ1Jj|FkG=VCH>x%;o~vpt752y{RfB(eX!cYvsAyvD-C+{fNu8* z%b@EqM%RRt0YQY_8wgWbmWWNy!KG1|^r``&b5pIYzkv%bj~4e zibMGL@Ru^Sjt6f)OG}!nG|eDF1!s`R9rZ*=ll!AHK3pYKI4C@@0qdSwNv`o1+6=z) z-I+*~A5`fL1w~74u1`B6F=rZ!X7`2%l%z^|0UiIr2`5;#1Q6H*gj5oruaPU?4f69{ zO3d$oes%&gC-c?&U={Zcj}5O^ZNY?*x_a9@INg;QJIKU2B6_X0GiWhUX} zUr)be4%}rcZdCRgnDwTwR){#YpOpW6wIlkdP1{?Lh~K%gS~YC zBpl_3c4%}|2@k~DxP}V9vHTObgQQc#8?`o9e}j_Mj^1xz{#b7BT9ld%Jl)t%9S6UpeqQEW1I}8aw=EI60@1AB#&2{k z-8}WztZtf&DJFcW2=6^I>E&)DWm>*!KHuSain;U{?5kTGid1Z*F*JzPY#o} zYt(KL_&i(0nZ-FqRhor-8lsCXESl$fi0;wib}Syv0KjDpNZv}nIx9grte*|`f9i6!zE+8Tl;nHBR$@pc1ias_k!WLUw(&* z{aba5uditZ`?(lt&!6s8?o?((BJ-Qi{{|l6CU4fskn=k6YwI3)U$23`0x}QaGge@> zu!17-%dO4C`jZ*Z*u)uWjvVB{sRw5tjy|ohKah#^9KZz+q94YtBw9;Q_x6v#Gkob# zzHhj6F}t%}wlEs4v6=h^$>7H-{w7iSz#6mFZ5Tv?cRCpS1`^dTs?oJJ;Bwo%^i!EH zbS&<~R>a(5xOULaMx<^=#|JwK>$o16Hc(!ZYc1V1l17voiXyA7(~7i6Ylju*v^Xwb ze-eia{uQ-^-y$TLzfXj+AAZzt7W`28;epnhT<4TMT<(WK^I?)p-u1>p&UM=U)Km~; zV^CLYG3m<8{r6utn;+^vxq~AQOe!cJFIwrB;;YPEw!S=Qf7f`%0lenlDuphRnZv)lP z`9PViX1_roXfWZONWlGSV)jwt3(LI#eUH8MNXyCawc6+Zd{c@j3^nw=Am03$Y6ch= z&)?YzRbk6h#`;|=ei@o#rL#8e??uBK|6DGroJ=#|;MoZxd#@ZtjL_X5Sw*N0oJV@4 zK}1pS<@g^1V-FxR-vyo7p&zu}=w_An z+fIBMSX0F|X13B!#*@o4zA4iB1#(o6^<12JE@gn0QMuG6-~l%r=%3z!zuAGWZWg*( zSiGpse)4+MCF!QnH4`I31~5IT9>3oc?s}Xes41~=C3naSGiLSom{a4~8dZ?QqB4Gc z*HQl>OA`zQPQS#KqwpV3cVZvQ#wblnan9rk_^A305#rS%nnXT~FkDIUVHrPTSWiSI z$Afo)pzwSj^XaT>8H7B|#+vmNm1QdDTZqp8R#f=kW-s2R)fWrzUEQ6qMn|E1yQ0q+ zW3uDr+vALBWS1k4EaB+Dix7!UrYberyC;54c&aH$3K#`5VAQ;%#+qL0kE!izgxUWF zKG}Gb6p&2Hz6=jU)P~iCI+#aQk>=~=%tfbf>)uXSNZ69D`p@j*Kh=X5Rul)tE<<+Z znu=_Gj06q5c5qC5fmORvC0}z*Ol)C-d4wsBO;^W+Q+H0Cc4{3Nd~g5yAo*DQwGN5% zuTriD`|V3Q|)OyvJe6-^uOTZ@94*=)9H_ri{gS_2cKBF$IYbz zyw2kTKTKY#@zU+ZgS7}z?<(nG6u13HDCXD5_OZbX4$ai2Lf%#%~C zcs{l>I>OX^S~NSR_p>FoiBW3n2%_!3QKB##@9T>hIEls3jBgOUvd{yaufa9;nd5IRtK6M z4m!!+;e}c)m>CayYWVCgi|)%3#;)T{h`y)23Ps&sDjftbh{U2@w!4H`5zf(qxMiOqi2k%&cj@ z?L7C997)4tBjK}Eny)zQ1D3=0>hwvGbP}a;yb&*A8X-^hpVbd|@!`lb_*FzITu!ZX z_MV#0Ek_1Di-*mv>4yG88O7wt15K<=iH7Is{$Z37_t#2hx^y7kFkn#kvz?6R9U@Ag zUSn41+>bd8t<^b~n@u;k+q>#LE&aanrJPMq!4Jn94!p(V;HdB@&D!+|a#!JlM}{iX zsRtyq-Q$wV_5S5PD)Z`vn)ecuoI!-am#dm>YI1*DX59y`wg162{r7{Ay4i|2O1L@7 zJJneL?I*vkEt1v9lh9;AvrB9+I4Sn`sknfLy_bxb2eG#^wv(D-n_!=Bx@^?mjEfs| z@+>yed+^iuDH|YgFY)RL5m7LWd4_*VbVySezqA)La>F>(5Ysgl7v%^nVJ=Cr${;xl zQw1-<7T4qHy@B{IRbva6U!(6cI!VCdHK-lLf-Fuc@Wv$_o|LIjoh+a9+*e!*eiXdUdl9gE`aba&-&^Hgur8C}HXkD$#-?8g&L{8e8^b9SjfvFEbF#y-C9ZX0 zT2$xN6!MeCncU(&C-k#)L1lC@GrTjxo5M z6)^O?iuN06{yWO~{jnHe!Z${BGd@HDqf%TwdDDS5M4OOG$`$tN-mfa{ z%J}tTNxT;iB3U|D<*`P2(RBgv^KjCZ*4K?np~o6hudTWy>9FbAl2tp|?E9Wa>B5U| zpB|(G515Ci7Z9ARGjEXo?fQ#lgSZ8p0bOQgcP)SqEQn@2!iimz8S*$&s-oa&7|YLm z{TE{dclEUkUTw9?hLXcqMtF{OX9Uq#06P*)~JaGl;-} zy!0if9=$#oPO93a49xgyo;XUId)1nEb13E1-eaJ;L#-6MS1ZiCDj0aN0#7cqT#ibh z3+RR~_eZbR?yZUihgQvX$$T$UO!}42JT#?aRhr@ApW$*Z6L$R9Zy-1Fq-CTKdb!Nb z3zj|^)jE#!k4$5?cNDqG;`dEOY{4uQDy1#KUWD_OOLvgF;;A;ZORjA!2!v+E?lR)e zOs)ybSBN_5%&|!QVk29KpEC91GD9=QJ1Aj$NiMnTpQiH`z6&b7#5ldyZP`@{X4J7B zS0F#LY*SncS&r9M-}#)TnLxv@|4FKa&U&A8D|55X**yWAk#Jc?&QNw}!;se=^cuBo zls#dTvP?P52QfVe3Wno`;9hECb(e~^4nRVJI44s3HW=Hth~DVxHD9G2TDC0PU)` zqt_FJba7x%)($^d&LrxqaBx6?#&f}tNRSlsX~NY9Yb!$3J_YXnffJm%j3$iFtXSCW zUhp?zivVMna1G=SH@-q>CaVybOBK8UvToRqv!sTy06+we+(ehV9e%Kv#p!RcXcNaOzh2I@i8Y&P#-S>d0wkd?5}uvKO0I*W)=e16d+kGT zP!!*te){1_m=`+FPEY$@UR=C?6MLR!8i;CA!^O4U(&x=s39irfSjKtXE#9nU|aActKNmyG#(k1=&HedKjrw{^Hbl3M$ z{;%KPp4)d^4=Cz4F#mEeHC}WSktg#xX{h97ft6q9E$a%J(xexqkZ*nhGr0W-jkuik*l_;G@NQ>3)GtWCTpG&}2qz^71K3Uo6_6#?vfZlTSTAn(-c`^6=>ouxq?7Mbl z(H*eS*Ex(J_%b4)WY(1imgSU);zuZOUTPF})!%k9$A#VbazZ?W(Z{#x5W{99J}dOf(0#bskH2XNwfUB`GL@8hJ(-imy)kOn{) z)6xN(#tH)_11D`oJz;mWk2TGlM3U8`GECj`9%4#lXFPAr^xU#>sAO@zz^p$W<`h4= z+fMipQH)#m0N;ul8T`WWT*b!6g|V|@$;chE1T141MeNYXGCUYF`@c%O;bETT z4NXJ}ifhWP*JAGK?Z62xYE%PGpW1LYsD}pmK@I( z9`34fko+^Ge9l%}pq}srU2i&VKT9up;t{*X!6)!>S}6761pA3e*@*Ka83_a z-yjDe!Q~>)s6db9axa`=;XY0AW{Rb|l|hLyRRovTMon%ib9ei3@8u;y=i}5ZEl|ER zN?BdluM)Xr`T4xe+8l0)ywXVKRHa(Khrhl;0x$o@bvt8$2K3dfG6EnNC0uhPJ*@Kb z?tct%{}}gR)rV4>Il>|}dTRoYT}6{S7J}xvye55-lo>>%Rph2X%SksS7FqMfq_LD6 zj6Z7ZKg+a#+Qc!=KNW?}c%PcM8Tf4i%gQ9~r=y8UC|HZC0eR;E5Q^^VCc#jt#X3WW z{`cSGUB|vA@x}A0?Bm>3#`*6OeW$WNJ970Gt|O;dJ&$)_kiC75iRN*R3c*bLo;_&a zi#7`rkY#O=RJg#Qq3fJb*qnz#E<3p^lMm)p9h;n7S3IZsl&zWvLKwfpa38hK^&2n; z!gXWLU=OUvN?7it>UCaJjMpY7aVms1iTnQqqEKKKpWKtnpy>PY@`UyMEVgT7$n6ZT zn~yma{A^Ub^uPdYtyV+hza4mwYCAt|{i=)85iqh5UAXJy(|ap9xs9763%X*=6k)e9 zlKyF|C1|AVansI=c0MsvZ~G>ubHqt9^28R;?ykV2Hh&06E5`p zN(#2LIMf-?M++j91f4L(4jm@2C)41_=Us59vU~(T0~AV3PzWU@%!mu^^cwgyy#lgS z`WxstXN2c-Di z#JfGHQ+xYe;2}VUlqrjD>8ik#&5~{!G~I!_z#VqiDSaCC2N(<;@`ks?IPN^bGG-H+ zjFuFg;ne*FK;1!1PILQTsG$qfvxZ^tcZVXAVV-3tiDYAYRTj$5Iu7t;7OFnIvvw(Mmo6$mH|*<$%b=U+}a1~%er^Et9BOOV8V-hY+05`N8M*&>l?yi9+JrVxHa*lIQ zPZ4=|7nwM}JK#Wk_dib)mkhNO zEMT+ee=5AADfCQrjw(B1&J0Q8Y1fMS z+|~2^uW|F0cHtR+ZwYg|*2sx~<~Bdwp1sg4E~XJss#yw7va)OIy!iM|3XbuVvKEo% zug&)xxSu|_4}T4a{MTpH0fxAp#dP&#ygc>`C*0Nqvdj7#sD;bX8vu#XhdXo-1p)i7 z&#v@!5JdoqYEeRy{ye-3#`29u@kdD2ZT=$j+)*scxDSz7j<4AT^Whx$4`qhbuavap zsB@mNO1%`M%rAkQ4~8kz*SKzhL4~vLRwd_*g_kU7l|?l!sT0PCoKn;17sskkM6wmY z#2B}=1>4&kMq64j$9B*|axyPJIf+9noC7=6IaRljAfOJ|)_NvV9)%{nmjD|pCm#?5 z^d(?PfW?e&ua7>zIua39iCJ}@V=Zy6I&I4E`V4vv52&5nZi1eF3xsWjG&e!%e?|R_ zI=7+q+<9cNYCP^l-F>~Lz=trXI-)){_D%GlICLJvL5}SFrcTilEC7gc zkkW4w`9I{n_g53$-!>YI3L?culM=i^L6ELMXu(DiAqoOgBO)RoT{9eV+WG zW?-Zqph(CB6@R#BxEy=fqd%+frLX=>jOD#Otr_5=d0NldbAh0b(w)hSqZ^D$x1+$o zN2V)X%boHYt_l68p{5yt8$oDHL5*e!Y{3msH!C1Q(`^1=Mj{g)SU9~+V=dJ7)`lzKq zxY?j%H=gZa-Ps8Mj4?C(=SI~7M!ToR?sp!zTuCQ#J zaG#VS#+CP?&8e(kt_|r|xj!uQXk6~yT2Xf!$42l#SgNOBsZQU`RnH_=Re{C=Bkt}) zCNhYaQ@rqH=ttkjkj0dAWbI}is(mS&h6EUC2_1X>cLz@LH*&^Ap!GQvm4T?B*CNgr z;~gq-lsyDReUDTOXP+XWdTG49ki+tM*#Er0|L;w|^)CzC#TyY?b}(7Bh>jBGBbIeF)ayE@AlC~ie?d-H z%P+7)!jjaUh}79P=vIK7xLJd58~@0q#6=u?05?h_H>qmMdv>X2ah}o=_Mclbv9T5m z&llO7wC1b>cg@AbY=zKcLjAd_-Mw8xuBkw`&%S-R4FpitgX{sRh~P!x3<80A^JH*` z16bG_4MQyH*IxO(`sWsBeM&J9m~e6mLbN9vvXZ!?H-271L&fzzJ&FPRXU@guIWN4O zmlg{C_C_pRn$=hZz)SZV8VZIFe6+kO9U^1Koe+oydSUtoVFxcuOP!hWZfW7$6btK= zS@hnb5aosZy2#-Hb803Z9fJH&0|!=cn#1xFh3h}a7aGh^$nHaARFtmIHEZyoh9hK- z8B%bYAbuF!fT01KNy|`ftO4-w#-jjpRL-4x1wusE(pDSjk)N#B3vj;%m^7tp)1deA zH0x8(@a7qPo_NC)&!jK?)Y%gx)2m+J?cgPUf4YD7WW?beIO6E62B zd;(4MqQ*K(SmU(2ScM`?j2`-7cek;5f66eY-({QXx8*qE7MUBFd{&Q%{aPaapC9&F zGOwg|ZAA;YgZU*s^f?}vW$E$UG0u3DR0^<$ZWQj06A4!Q!|iq?i<{$&u9sL#S6tzG z^-Rq|#-7Ty6&|A?9ESg{!F1H@4d~!0^&1wcxSHQ|qs-hko_T7-pPe^lOFrvpyKiP! z7+|40iXPMJ?6A+6?pZnt>BxQ^{UOjLL{n`CMfkMYCU6$4@YE{+ug!+np!;>s+aFI~ z$sK%Ol5**W3<5=7!u@{Cq#rr|1}K&h`?rq<3DjQh23_(70KzSUD=4XSY8)5*)6);Y1;Bk3@Mt>Sn`qP6PZkpT?>HLg<)NKWAUtaO_E=#N8b{du zLU&M=@`&L{dS4l}93O>pYajSI^QKw-ZbhhX@JSFySgrUOWoir;T=Yy*h%0d^XzHNQ zx1U(zaOB8j=M3_^c-NKVmi*)7kY4%GNzilglT!TOeIB#~aCorKn@u3h3mXu5ldKvz zEc)BXRx9~jDo0(&Kpt{PN`UWal+=43JyEDwx)98~U;>V@nXXqEA(0+32ME!#k%Ti} zPkz(_*L-hr{Nkk+_~cc){MDQ-XJ(-Kgwgxe71x2oupKofk+17X<bYP7GEfvOHK^Ps?zK3aRs@$t_@xHK(e8v)~ zgekWE&?wT8_fM#1hYqd4=R{g*^*?&y$6G&%9%I{RVL07Dkqs}RaxwB6hplSmYK7Ev z{^8r_^lrxGX???>mQ}w5ZydSi&EOF7@|!oeI#@U85FpW4GiVT8K*7Gpl|1o(53XiE zr_YLMMOOEo^#3-v3{=zA#4W4wWiBhrA+GE|0)L}=4F{0O1=wvV4PbWn8MvY>&(xos z-5fJZ=m;pZmlZ&b!afW=U^PFf)vFm?u?H|j|v=j2q|eSKA7Bxc})lkt(| z0u}2wuYCo3SHaUlS-tc{<3LbN)?mQEdv9qk7-w)fQ*XNa+i}+piHOb_fWdxB$OHa> zWey-I$mWr-(w-MWy>2?Fik?%-u>X+&vwZ zhghWj~qDp>K{AOmnTfAzoPHva`%AD7#YCo$fDl!1<^{SE}N{Nh-U8*%Ay zh(%pUIYfa8_%N3A-B5Vh>1Tgjqyf`2>I2E-ypry`QgEZb#WnuBmM|lT${Ke0-46M{ z{*D;iZ;Q_f z$|a{dR1j`2Q_+_e{eQ@QC{VxQVpEi918ib!m?~8FSXrO3rJA}br{~F^K7HyDO4289 z()NH?$ZZD;qw|u@Eu61&E_%FvWE^8`DgA0|SL-y-`-12KsfuR{S0;uVaOxo=8fJSa zWv?l$<1T%UGFz{dwucANk~-jT#aBO>9_MYp9mtUZ(q>gve>-j@P*#pGC}5@_MrsaN zR%@c7H8cZDSRd;MTYacq0D0V$s%CI0H7=bjsx2ln1&$hxhwJo7IoL{rr{9WHuEr`S zCg}w~NsQD4fdK=Y6Q+b6Oi*y>+A9bj%}!>1#?gLPzyJf!;B^zTXzSsbW`y4j2Fjk6rwT>r2Ew>7+zjtxO>U~#$=yW0LI!!*q3 zAB}tBWvtKtOZ6A`0Zguw&-%LH;bi(KcZG(^EP!oYer@2n_oVwR|F4@L%Ad`%g4}xf zmq0U1F)!0si?Kcc9iH_EvG0`JF7BkQs`s9@{?nUh&*fd#RrlTo3A~$&3F`zQtyr*j zvnW=JMw*qepX{yU*56sfjULF(@F&$@NWrVyW}=GayeTncUyH+aAWb-$M6 zul~*y=988}(lS<^dtwe>SV011TX1L(#+>q0BbILr%KjiUlv*n!#iiFMFwdpttt}!p zTYp=>dc;gGxLEvNl=B<#i7~ObsP{B5 z+>d1fU^Qy<5?TX~y>NGbTi55E zP0fo~U*naVt;vh}M$6G;; z6B+F9U^eAbE_uXkzbU#iYmC#Dl+kPIHW;&D!Lrj4cH^_)v-m&KjU8IdDb4ygrFQ8B z)u4LASk$(vWNMSvhfE_;|;sDpPGUZGK`fHa5FzX>|c#bon_K z&VgS^30@mJcqgu~o-}s4??Z&`2_v3A&T!r9nb=x`6y<*L^qS@5;*e`=ejV4F+g=uJ z23h#{+PE*|&qB^I?&hxEoaT&Jkcd1UL;-D%GM^Dv>V%VLf1Q0;G*Nb_KTXdG@!74} z(mVJ~Jb$m&jn@_(?W#P%6gm(yPkxrUw~gNdT;NXK!BxtElR2l{vD8Hzi{UJ z;v4w*MP^q%Aq~H1y)&GjADU*1&i7$!S?xy09fzazlxPs(S_vaIXg1vwP z{&R!tWtA&7ne~r9XmeMlwTQl)afHns#BcwB07e!ejXrzneNh~xFYP9UgaBW$F=781 zb`TLR<)ipuLwz6(L&cri1QcKcL1!*)Yn8KzQcb&e>Qb49usLEqh;z0! zRnT(Fm>ZGt7i9bb%)9;Af~Uc&b$%Yx5fg~i09@sFfI=L$MBUWp{IG$97I#NqPW-^27XrXdPvS6|I&8WRK=fh=Lpv zOLj&%$^COT&An_GLV`F}Mz;g66qyh27EO zLiTAvC`(cHgKm{#if8_UR)4j&cX}0Zv2Z3-iLT#jWJw3@kmbgenw8B@&X?c!OEP`! zTJ2@wCBPnj+CxVh3bF@*{1F}TapREa!r8c$!}0{3@=+?%yw9>Jhu_|$xzKoxpBc3ztYcLrB_s>ugaYhO3a9aDBtXo z5DoY21f04T5kgvOFOXt=-^wZ#&e!i1C$if;-e{8Y0+!Pv7ASl5KcQM2rNhShxd|Yi zPZJaQVj3PS?LklMH5FL~Xew>W>4^6)-DQ(6O6KZ3>FMYumFjQ>zG`BnW^qqKULz`5 zn=xGeeufo8FI~}k_A$^`rmQdR0@yE{yOr4#!8zJEAI`F_yEjD>_NC~IRNl);)8my4 z)Khlk`ZQB{t0wuW{(|XUkSTS4{VK(1zsV18FPv?y)}8&#;~)DjcJ|~Rg%htGI&DgR zY%MAdgrc-i{2KPTWc0Nm*`1xc*r0+)Z659Mff6t~7YGO{00e+lF1mNx>Z%3GA_dXWMe1rAYi7o4*=L&LR?X9ql~!v7iE0J5!qqnuC^ zDtfed(!n*+_rhyw)*jwTZ5gNd`MMTDPn7B(M}t)1V-b6?6yoc`qIDm=xz;U5&j&$5 z5pM+McZK#P`oQa_VdDhS4qvJDcWsMA%DL+g!@qsk)@GjhcIISX)wro12(ZV0fuB%n z7LlV|?tN$Pa;(@eIyJNP?xx`!AS>4M{gl&l_HPPb5h)u6eVrzEA`oTWG|2iD4-%om@(gL)elbR+qM&5JZtJ!=Ay>enU?K)j7o&< z2e(7{txfq43%caC%?K`J;@v59rhsKea&b=To!U*826FGdhTJ3dVh4?_n@h!WHYGIk z-JxygV6WXHj{hrKZUqByVJav?#@8knly_{?SVJG=FKbbr=!BdAc{gZK9ndASp2(`v z=jJcN(A@W>zKbzp&g-v!%6wdXOW$n}Av0BB(?CY!?5J`#EBj}{Oz-!cJJx1?xiTiK zSMRuIr^d6rpTbaY8-ybOc=;_6a-(|TU^7!|fKBS|lQ;jteZJ5hVE_aQ<|Sy3XM0rh z+lI!ciYB#y;(RFq^KSXcP?_=|9F5aCcm?x3Hmbc-(9qM{bZ}HBmu}Q@DxyjB+@SwzbrcB$g)eM0E8G*XxTI2ZC+w_+OAu&5pdq%b!!} zZb$BwJqvWY@bg3}U-QqLqLF`?Ppe#iuqTGw04vI6(|{RSJ(&OX8ZS$k!X;wiXPZNx zzNP$#;#VI6r(8n(i0O~4yFtrpG zaKa%Aws6aRc^?UtsfNS>ysO$y!xX~gOD(N`X3Ef(ZT6j??kMPSOv2ABhp4Anb5Heck(n+wz|rja7t+#wKe)>2RP0*8)L&^y({SIN#v}8tjj-G*<4QaJX678r z%V%d4(DJ%ao?(3pivySal`XAonKybS*82zK`r@L`!43P4vp4cr{~MM3pXs_R63hvOYC9?NkAkI!kXh^LF}*!#Duyozn6H%VzKR7tSL zo8}F6FaHI7Yz|=>5WN6xxe)HSR)-r7#>Vgh;wR_L)e66@8l&;+{_uAEyNiC&=XuF- z)+a9c9|s*vVC@D(6K<^hg)f@g>g)OND2%RK%x-BN&pqBnf}!v5CediRVCcYin(dju z)06P;a~LBWqGb>ru^fG6WE6FV0o|QTxY+@j4afpnPS~eFiVv133`v$kmzOM;qC`3% zbKyYxS{8@Xuqh=^4DA^Y2uf_rS=KOYbqIG-tTA-sf?~dECs?3Ebm=?&d~c9vrd~CW zv|xIqi8AT=?J>}L^Lu@8{dL?F>U#V`S|U~OK2^LjHsbK-oTz6K9PD{K0&Q?k=On1j z(db<4W^t+^=Bo=X)jQ%uc;<=A?%OL&OQgF=9u1+fpzaRCTHI8M5qAeiq znTWnQuH(Z)-x=Q{lUFL~dz$wJAAZ20V1x9vpb0(kJE9=@kdr}2eR{Te3qs(GC+3E* zH&#r@9fx;*`71sZxS8M&Pj&YRWJ{qXh1uRq4_CSKaJj0Ry&(t7EDNt##%XtEJqMv$ z_1hhDgFsm;QV3%Ni75KYZ%&4A7XbjN4>yg5+K&Z7z5m-t`oBFAy-LhD)E@xo6L|~# zwQRWV8{1^4`X1oT0Q7hxUmV-t8?yZfqV0C}m&FgEv@lECpfb6)b3Wq%b3zbuJQ?sn zGC6dE+&+kBUI{(B+bX&)G#952)YqZCp4tMZUb(pqEkVU7_`YY<4ijyWL{>&nT-p|4 zyOCMV0BqXe(I^6zutJ0_5h?@w0H6x+!UiA%blqHhOOgNjAW+Y_#B2zVFFj!t7X!mf+N;l-hrH6dz3*qv$4I4`6D!f3401&ql-&{}|ggTr3 z+y3lPuF^Y@nBd2AibZvj8m01JRh!mhEc-TXEt8Yu1{bIm4Uam_9mSzV96#dVELM@CELZu*RpgyPbRGtTf*e27xbnK|ycoB_ao6e#$^sUZ zZ@j?zRW<)?Jhwl6s!_{}Mx1evGD8bi)eVaLDbh%B*qS>3jeN z$e$-+H+Pwlp%RtrOjN!0dDt`qhCf)MgHGr^gBsrjS?~>pg?7ZFfG{<=i49mC3I_ai z_wvv{$RZ2_$e&Qm`3A;1TzvnHUygc8`#m3Fl&M85{u{fj;6p3?#;A z2*Es2&ZtK)ltWd?mXY!jb~H3BwSo_@#XfFthGYGQv`zkS;Ip!Rm*`i&|3v?&3Z&GbIAd~-Rp_{tX_F*m1E&|ZBgke^3}f2v>GW57s98llNITxjjQzTm!dutfa9 zbqTlqO=M=peYW|bVsU6dTE%g1C3kQiT5$1JM-^*H$mWi{a;{l`bM z>37lN!@jA;H!AKnp((wf2l^6yEM2fn1E(t!`j>qtN)2k=@4SC4VeFY_bS@uS!GG(l zSH`!!82by8pO=$dGjo&BSDK?`Adjnh74}@!_S}skRjr_FtnTe~zjeHm>^4d)ip*~j zYFh0n9}8VK*QMIW-M6(Sq$xeE8JmgoXozRKJKjwXTT>?{Y%$m;`ooXSDd-Cc_qwpC%90rHKwtf)5!gRWs(I`Cz zF?AKq?w@FQZ?agC^s?uQ<=RBAyO!w)P$Ihal&y5fV5@h%C zMrIF2#AT~Tu#I_P=IOeB2Xwa1#(%%=Qd9YI?aRVNp_|h|Ua++J z^B45xGit@w2O+$bjIvKyyrCXsZFDh}*jZpj)xw=PlnU0)cVCC_RH>iBstlD{Z^~nX zf&vyYrp*^{i8*|0(@Q7a`K9PTEzi1lVa(xpN0K#@}l?{b5y;YfU!3wi7BH zV+RfHw-~50Y@{xI-P;C5Upm6@G2b}e@O(riyoFFogVQ0q+vEz=PKK%2$}e0gu9^Sx zF7d2}UaJ<)G*}@5-f!AjGICJOXMcodT)?0nP{s2a$Wu_=`Sg1jhCe}^;W0agNChXD z*g!qf4sL9t9mRp-36io;=TKRzqigk-6}C=anzg0EPP<#1{aaPC4Y2+Ka*rw1I z7TRzOURU!Qw>$XoA?9$`707Iy$HWRbs&Cc(z|iE+`ThkprM<_*;9?b@&SAXx{G?Tc5}7$t5*{8&U0fEB435eb?GnFi3rCV%8U1 z+vBQrI#|WF2HeGD78i1N%nzMsXcnX+uTj@3dr90BhX6Q}7=}4m4!RC=-0#K6e6%~P2x4Ib+nn$LF(y8@r=Zm#}LCb*=$PgE_?N!ft>@0gh zacx*PX<<)Z;jofiO*W>phg@)y)@{}}U5efG&yI6Zw>=!04UEMpZ@S94o+6N7Ep02L zAhGyO!(afxV~>QTx34~OFH*UomtHeE9^0@!RiD|f23Vk9+@a*YM@^T-{srx54u7oLk6Fl` zwH`x7@u>PVP|KKwCBPt(A%ZVzTbdBunV(Vho$?hUKgNG_e|0Ghl=x9Llo`x2^J;}8 z$1Q>h(l13~Hk-%x@wNv)9oi@DWN4o?tQgP~S#_X}`f=d9V&9juV^3Fqh?NKz;Zyf2 zo~9VxM3Bar6JX>0LqoD$h1bIw#0hXBiJOC|#+%l%rQC{4{yZlu>x_R#i&)5beijwj zDnZLkq^ll5F3CE~xW6_mU?7Dkujh*W@sb}A$s?Z&b>olw%fF(;N3srT4@7O1e-E~4 zi`Q*s+#)z;K8ODW`5Mmc1X9?!(?wYQVW%t!@sSyPd~O7be|&{B1ZH**8!k`=j;_mG zLrGhvwgnsS&srbRm!Q!M>J4f3?Vp<%^y5(&@RR#E>s?*3=cDW;LY6p->N`{efSp|6t4~e+3p(T8458n! z75odDHT?CMA}^q8DLX3680Aq9s-;pG5;^<=Olnn-970CsGzKY+Tl9D zqia1&v1i8eMM7wy6yUB2yT6S{Qv0>hWOUT-kR? zQA|;dtA9bgNlE~2c1pg*->T(xpz!(oV!IUu)~0Qz-Nf;PT+g)Po-^L~Mt88YtRD z0$$UZAV>|bsQRov*kJ!26|$<%=(RZFN5)kMWz?OeG(n}cj5s=13JW)W_^V3R`n)%y zPty}sT8fWh5$rEA0w1^H=xhQEXM!;GqwlQsoGq=p6j)?FV>=fx{pQO=1u|5t@U zULMoO&__p^ssP*Km8g59EsI+N&w%dXuNu_yIT=^TnW_5g=R@UQp3+jmumU*Abq~*uu&({~ zHGAsDmo_5f1kKUwGWXZ1E4xi&1*_G&b3TLg1l3K#m-BMGRdeZEzn33v{z;kP*>j(1 zY1tZF3`oW~ruJKwBJp8m!AkCMyY@PpE0z5r_A0HiDZ_~Az~#cleGKqg4|yd~CwVX%9HZVIea7fm67~Wd1?*sJ zkZ4ci%6Cq?b;{CZfkZR9g_PW(BtzxNoxr?UQU1oQiqGJ%^`#es+>P@ds$_JP|6m)H z_WfA}tRuX&fPSd)3U*pVL~PIgNLU&%wuqLnllu$OW*8>jn_)OqPWv;;&6)TxbA6P z;966rj{_@#*_=DCq)L#~7>;#TjISsq{)FAPfqwD%>GUqFx2*f2vr z_g;|CRo9b1{PSv5oW(3{>*Wu`3|bbF=^O=&mpsjT56Bmzs>FYvX5b#Tcx*`Z;Kvqd z2IIYZ*zWN~!$V;U3~i4VTX1`2>4on;uyWu6b1G3B<_<7n&uCg5G!lbyOdAH4s~kJ2 z396Pi-{DnNXFqy;tiZIOdCzVGXQRFA{89Xw8QGCktlZb?Xz#;|&a7Ixt>vGKPIN?G zj-tpw-b&zw9AD76k|*FDfpA)#g0be5#L4q^yhjKT z<@2j@En}Hlq_}Q#Wr$~g!Mx;+=b->CTfAw`@TC1H-tN2Ak`f>wUSs))3Qn?z8EyF@R=9E*BN-P@j?ehw&CN;+B}bz!Nj z;fvs`q;0FP_&!c{2mEm3zr(3eT9~p{=oZEWd7g8J+wATe2(bk!rgWl1%Fka`g_3;NY5{(h{XN$UMiORhGSw;>l}^Bd`7F`wJS}$;a@;(}hDM6sO&P`rl|9hWgRp zTs!Hqk8+qR$sq2nZ}u$}HSjU?OS@_)d@+|y$zn8bv#omgrdeOB2UOKNZg-_`wt>HO z?7{4{xNP5h(l;7`9!?cGzc|Bh(3?~S18Mkl4W>BE5Z*t&=UGpNhgN-k9HLTr1#PaO zUb!(Wocy^zJH58AqJZP5R)6Sa31QvWoR>yF=+A{+++BW3N&6gP#S__zaJRQjwgMRZ z=TjNyx9jjxs?nDhnPZ(wb$Lkuf;ApsWEZ+n$sk&`3@l)+M!)O@;FdlkF>^N7rRz&a zsFbCs^-GLSExZb2+kKGnVj=ap5QXVLiyB)3`qr*Y)J5p!2`XaoKL>XwHYId$F?JUn zMBcZefZUyNJbili#u9{G)>APv5oip*1SWg1pBcfC{zMH4^j-+xcg6h76288n(eDx zJ@&i5z~~=nGV3+u!#>}p8LaKM=b;+=b9rlr5)nHQ`AzaE8s}pV#Q0S^1fZgJ-gtp5 z?&w#eY)%j29#lz8ddE;B!U(FuiYgu~d8Bo5=bCx5>eDrM1Uesv(cd{Z|zt@WBz<5IZMaSybe@ zTy|M~5=Z5!mmTF1Z>-P3|Mbng(U{4c1hF+-Wp=0Eh>y%2dG+o|NJ1S(@?rB;)$Wq{ zIsTQzAumbrOgxuAo3rK4xb>Sy9nzQZLXoO7G0ZzY5@GM>TM4h8j*q!9m`u!oV|~&> zwEWMk@!^61Tk?;J1|#~8*5`iIb5$RnSRmz%WaJsqiH9z0QuJ2{$La!}7a|P(HVIZZ zm|^7x?tT+3fa8|!&dYU_I=R?rq>`}dJ$M~IIxtSTs?>U_!xR*g;U}i5A)?2q#vj_} zq;@f|M5_s4c`sX$pFVH$BWW+OcE#OjZ*Z`8+GYy3cFFOYwA{F83&+gvlF*iZhboB6 zBPd*2B?EUOFK?!B=}2t4#a}|_2YkH62FOdL2O@(Gf3#b<8E#=45$}S@@Z5}|kiOe0Mh{PqPav@k_19|L{G)?8qr?Ng>uPfO zuP37ODs?3)rAJ^Fp_p^QV)6RFlIGQt@@p45G{6(Zw?Z_j5!(DPjyjp6l6*sF;Z*yN z{2^J-W}fZh7lS}XEFi~L2s`mbO{>SXq>qX-39aEOXA^=Vni^L!9L3o|bVrh3;~|QF zIbZtTinK1|_eK>5t&vo!4@!_%ym>lB^b7}gGzP>SQ9f1_Lt?&sGyVJCe9Rlsb5Q~u zpE$f4*FXszkO(3hiX>4{oq0LYQhDO_CAdj^9T&@7Vy%F{@(b`7=_qp(O~0#he%(9< zcdYKEq0$&c=!+A@{38hu^q&#f*_p! z&iEc&Rg}P0CCN*-y{qOLbl_j0a}nX(B9|+GOx+$P_w=Z+&7btQi_lAzdTiiDe$mXv zQ1;!<&UUW3ux%+fatO>k_b+IeE<2sWKmB=R9H#OYbi!)mB{k#$OOU)U3lHHx8B%Ir z%NNLWX@T84|8}o@vbeLEJi!Vmp79>VGS0*e(Q{nfMkl=#1NQ{7xYXOxr&D#Prv^(~ z-yP$!f(5x7Wl;xlZq?tWaK&WmKVI|EsqW-5V*a!f;=~OZqmirGj;*0W&-Ou;NZV+F zn5ZM`qBuvUl`kTQ5q~k)kxe<>+-7I1c9)3K`c9TXf^8>a4AsTXAZ_NvK#51wbH^#L zv?!|zLKn&^O=Dc{S^7@R!~%V?qlzZ?U85?_5e9t#J|A~V!1QU0s0jNZMJo9jmO$S! z+nnrw?pc{H;WD*8PR1DoPS|Gr1-;ecccvR*#Mdvezn=(mPvhym+2hv)rk_)~_w_@? zhcTowpktQnpB#@<%hb;ymYQRykhO(OU3}`57mi8Fz8pVqF6EwrG#~dDv@}=`3X%TG;2H8!my|g7zx9$#jokt*Tb;Lw`_65=cb(S5Tl?hkeP zdBRF2=%LCv!DPG;{Z%ha2}s6-0sPljS{Da#<-4k`r69+L9~$Skl!lDmx*FPVEOi+f zuK|!ce0w4e$e58I6blTca zj?TTHed-h4;Q;WQu<|upTbd39>@eM5o43+qd>7tM!UaQBkDt@g6Q*tWcL>}#pDMKi zh!2lKdY3ZeTZ7nCCPLJFnW^T3w`*w0lpz|@<=OjNewfW?^c?}AIWStGPyU~;nFcE~ z8ANI~krG&z3Xa|D!Va3|B#!_nTZ=?)CRbhyh7ACgS~$O#zg9`w#CnP?7`C2|Pt`io zKi$rDUaAsfC9!o_bC72C+gf@{99!D&x4S*K`Qjj=wZ?Dnd7kF4d9j*9iYEa)IGC%x zh_%egDIeXJgrnIlor`m)rRofkiG*WUj+o;4>bO|m5=?hpR6g^IM znjm0Tjso66h={@$-WeUyuwl8wJS>hMu!tn%b3g$$Am3@A6tvY;L}6BXp>vu5PiLf? zH8kR=WfZc+=rF1S#3p$6mv$=LDc1A*LP@A2L@dbk@VIEmLPrB}>Ae}bcp5NU89mM` zj|%bM6{E*od~&i~=!o_gL?tpSQBr2Rhs1+WoLy+#cVyb}xl!-Cg~Uzw?I)0D5r^H# zy1yU=U8!|<9m0Ggy!0j$KQCFj{qF}t;f*zo0bMQ9HYqB4j8sjdl0y z$mTxw;MbYaX0=3ome^JoGcxV;9viE8OwbbJ1RzMnY6`&4p;GPwKfd;P#Q?#I{{RZZ z8GtZk#5#Y17l=QGZ4d6kNWj4_y&Atmj9_X1CKX!%svAHZG!V+S<^lMfz=!^V-hkvr zze3s-Hfu2*sz)$(P=$j7Ta~r^Dzp>UgF$rUfCA5$J_gx-29YrY+=1|Sa3{lxS$CwP zwu663jo;FIaSXu*5Ryd3n3-_|~HDDDNgAz~JZ<1jdr{PqDz9nZhBl!qk&w$AnDZI4vc^)s$am>T>fZlf!nM37kwfuH+dUeA*CY)_skX7@huma;d__d#taAurEoIa2c z3>dZi@18IJ4;SeW;6CAtivvOV{}bQ-Uo!pw&^?qLy)`IrdK?`X0;dh@$Xd$L+@?g1 zQog#%OyLBNi0JmsJbyTXacHz7TppgD)3^y$Uf5SFGyK} zAx6MV!BT-~7XOsN+|lw`+W*(q^DO!IGqEQ@=NWHAI&v_igYo9QL`zMk!R{IVK*|pA z$p$#hc+b|D*>HO=(P}D$_@A@mCfyGKI@lZ7zeCBnaQy`VM%e%$_}X{>5CE!yu%n!{ zx}1GfBlmy(PJuhBSeF7j+Uhu9M|K}w-Y#YI1wHm-m>)zj#P&0S3lGjR+CDwyzO3v~9IXEIcnfY8_q)Q8#Oe4T?WUc*Uywq_ zMxpBh+6Zo3BKAud`BYwBU&CBPoiDUUvd+jpws}T=b-ysCwXemb-zG`Ot zUnS&Na7Zu@296y}{BDl_J zAq>w>QMfp@S~u*0c5P$*dx!FDw9((=k<8hFp&GZh3u7IVq-D7kf|HgG@^*& zvU2H$Nc$|`7+OW}_DRC?mA>pa9L>~}DMr5?iE@DP&WafY+s|%tEhzR6w@?V2&#iBv zC;ReZ{wQ$?c&;W}JaKfLk|-H{%XqcQei>=Nld0Oz^ z4b3N_-)MZ6?+~X4Y4mbl*_dA=m^x8(N$a_%`fOJ^s8g#KKUULQvF}(IPj77dV|Zxf z^RLR%NLkEwL6*=w_j^e#Mx_U)yD+CNtx(+E;MB2RTfw1Tg$wI1b^O{iEMgY13`Wyn z@2|0!8@s`yyfxBS`Ufq8H7Gs_kmr=5*WX_G4g6gC7;+g61whsg@=}S0V_RD7AyT~e z8=4Yn|32H0K9Fsk36#;HnBr$RpnC{X4OFl&kI=gKFyo?U>r+*AvEQDFE^gLt4iRm$ z1V3kbt%`4A-^7Y?ial?NS!EUni@Pfai2MGXf;N92YSnKO_w&)MvsS}3yQ?Fv)BA4U z76=HFU5mhuEg4gW^KvT6$|*RO=j@{VnZ+F zBD1KN`CRsNHOR{dCM&I1&$nCox^`^Bt3|t8(Ih?(_V2f8mR^a)6{eCZux6`cAEJq< zJAXl4D9o&mDXK$QNL>m7654zUc<=R=F*XJnX+mzfze9#-}K0=0{woBAWQ` znWO*JGk0OoP5=2r<{g_Z#$g~6f+^&)!SUr5yAK-fR~;by_6-=XFKe3TmoK{HPr=^iv%0YwUYwWO+ zP|ImozG3QUIecqR+$O+zPZ?p@s8F-fn*vYI_#Vco4KF+YF zmJB-d#L}BfZ4Cn~FdrOocW~K`XLTO_4(6F**Vs2sF-Yhq<=1Y?IkiMfea|U882VUT zxZ!y*er=F~2Kc~ZPn=Ne2AwU(oZ-{wNI-=AX{0veypk6|zFJYu#lL$x|e*tMTUs z^fkZEka!&UzZb}|njR%yAG@s;Hv2hE{?y|euF8U0%&?-LIb~aPjzotmIv#cDbOvku zr{=cE&nt*P52U0=IEoq4O6q?WB$1GnadZZu5t5u zES^_3h3F0a@p|cYfBEvr!>$s}jV!DNsufn|-kQnQC4NyD`&Pz8-1>$2u@wqb;MXS^ zAd29DV#4_EA7c;J6RdV#V%mC7{Ryx$5j444EG_4d-v3H8r#!vwVe-p=;_s{ya<2TG zG|NW``bQsXX?+j^uT%MD`I`JAlKU1@2{dj6i`>gC{&Iltq(<3WXS0t!$r`-ov3aM0 z-j{Elf;R>mX057SZ|A&iSUtG$)Z=pa>a>efwuW#qN6F8(o{bz?a~WsSi+F85O!E~izDy`yowU|*_9OMA$*1{GMd$AE|14H* zd|Eo$!YOm$$*z zM?zhEx6X6cCH@v?1>u15m@$d|CgigZq*zE$#pY0N>-4i4F!{GDHFIuy%5kw>38|?JQq*v=2Je2f?>V4Bl+7E@EZ1`JB_Rp zmU8Q-ik8z~p8f1q^7Sgq{pj8cZW|gg@!AKq;g82pAmpo|w_2lCbMU=7s!dPXSVbw4;05?a+9KjC{@O-W zfN;`+T?G{k`w!^}7H2 z{d+Cvd0yvrUFZ2;j`wkhhJO|>1x&znUuTbTnwb`cW076q-Mcv5uU{KwW}pi#$D{*9 zwSU}8zw$Ml2h(|>1f6b;`B%8l!p)~8^Q7cvYw|-df`1#QS zP)mz=Rofyb2WmZFY=%tiqf@O`XdcT!RUyv?TtreI6KD2+ACq0_dMt~mN%&8?2v7FP z%Tpj?7q})A%)><2YOZ_9Eq5#mz;od1v=-<)l|8-=(-oAs^d$>xh_RIHk(=>#k66LW z?yS9m`%n`Y*mi}C^CGXVidF6?J{~0svWy#kEzp+?T;kOTf~RP6hn!q`y3l-cb9sU{ z)9+F0a!!YLtn+8g3(!W0&Jv4;MIit72I4v z?J6dw40t(9lB3$)nD{*va`^@2LU*+JF&&tAEuo$3v1u8x?y{#xnJ^Kr~Qtd(_XZ7s! zcLO{Bl3IpA&S`17wpE(HmVk9bE9VM0j$vPaSjIn{ziQ%FD-@A?mB*|qLa_%A8c<4Z zpl%)d&k*P@_kZ`4z{PTorF_ZK^rcRcaAuEjITQn54J&LE{=Tn4iQh}vqp@;#{{=T* z(-s6ER=7n$TN264(f+hIK!1+mop=2Vy7aUfjlW>yQ9+-G}q{Pr(B0Il!Z5gXrOG_Q_w$uy82G zxY1rX^`=;MPR4T--3GNc3gkgg7aqb+#wtox+7)ktgtm=XBN*;8cXw%E4>Zg6(1b8F z954hbg~z7c8p!*WYiPrF4TnE7vkmSAKfe5sJ<3z(1_KjFv3DP0!ksm5_8=M8_r)d* zq1l9ZQIM{iZR9B3P^Jc8oC*#V@dlmAtE>8dyu24{6{XT6k~3QmfWfkj?Jt<>U#|_- z)p7Pxce^uzxX1OJ@56m}g0a$*ls`u8t%>dndS;PRb4gePGRVTp+P1o*RryAbXhjd(ZdQvCPc&I?S>08#F zC}ZRO5-)%eKdqBU+dI`!!&Cy5EJ zc1raB!IFN-(r|79^|`Jyq~;8!0ti1XgF;0Ms4flIGU?oS&KgnExcq${sLY~*7l!|E z0OUIyHhh@Wd9K^!7I7tTIWm^3)ejUkeWOtB6!R%vR zeUcm~UVa-C(z7W2$GZRSbW*gx2(U8s)!rl~+y&tQ?%gwS>=Z*Sy40Ij*J8hd4pe~z z-xx;cCs1!Ox$0|&WVuZ;C&Ml2dN@mtPGZ<#=j<|w>;*!PC}nJLvX$I=&C;5VxEK8D;qiYhuvx-pY?`P%YkU+5+}Sj2r3 zTq}OQKm|q$veuM_9|@M$Iy5pyMu7vS3@qo$s{Lxih$QE?QwAWWy8A}PB0-3?6uP%% zPQ3^50y2U-a>^6nFo6z+bEr-5M?s9BIf`poZcv5thpzn}p3>n;?lG4R4f_)^>5Oe}X=pYc)%KsJ>2| z8k~Ezw^|=6_8BhS5gzU5B!sW;=HK;%UR$ib+uQeniB_UYK=35w_H~%Q1NJ662Q54~?X_;UhJ20J7TEju82 zmwn4iUJTpfr*6qh*Uym2e$0RI#teFY+Hnd=!XlSC;kLF$p#h-X5)iTJ3Or2B7rKBb zhGKyDvyJ(c>7|633TQfEzniq5%OQ=hc)0xnnBdX6Ja~4+OznPBj@z79ow)z4|LWX($5Mi#`h7Zzd?H| zbP`A~1bKwU;I%Jaj$XIX4P1Lg_og0w0any70JUbV6~y4Yp$wSDWl~6hq97h?e^i?I)Zwd8t#SNx|qzj2YgWB^k&W9xW3IlK}7U?IOf|7P{T=wvj&kv=eD(7 zf6WvP@{#F)cLMDR)TBvM(Ce)?2JrDKj9jV$6>|a@NSvIenwYdZiz;pMplc0Fq5^f( zDMyX8Ks=yR(xs^$_GD>1Tx>f!WQEX$^x9M$G=>1?&AwGale#AyK|UnqvhIUS7*h?t znT}?$f`&q6h*!?>Og&}7yL92VfXb{(T9<~La9bmTm(!d;%ZZ@%AV3%$L(1g{t*0S8 zMtYoYraMmGNavZ|19>()8J29sx5bbP^{mskh4|;cCnd@g2VtuG-nasg<5~;X z`1L!~KG(+rcL0WSUAdqvFRVCFA;O0~?dtc^+=^wCA?t&X1MhwpfaME*#>nLwn5*s> zmq-zmIC1Ge!=op{|3!(7A(580=^y_$L?etv+g}Yt7f=0u#F!Oy3_uN}f-b=B&iGpq zu-jYitG-HBhge%6>}oNFxNYfTqQvYcdy^_W1TiIaRR;~fepzKn7z?Fr27XU*YjIvF zouW_;o1yI{v-PGtD*L8D1Oik2#HDa79S+4=_uyTvnxW+lH`I=Q^f?`~Y4h*w9%usS z$eh66E)Dv&dSp0pGIZ)Ut04hizeeF^kGYh1>2jJ7#|L&ae*243%8T{B0s3ENUSSP) z;RqnW6koKSyM0OD9v977#kMc_iFB~PR9u!OsAU`Jx=nB3Bs$Jex9D*HG)mU%$=gk^ zhCtWd3Vw%JuUI@3uM}#dWg9~VS0ms*fyDuG@C>py0KZIE3bhUlgHYcDKR%?|l4Jfl z-18rY-_p`OfIYE`Onpvi*rh_roZc;cfMY4A_6$sajQR~t%3CM&Z3Ey+ok@egouK#C z0#E8d{a5??nj3TglymV|frGq^P6T)ow91>%9P~dl^PA9+J&uiTC_b64^UofR^~19L zh;?wMn?TR$yO1TlF0jN>_5mi-*q4!CmV%+yTmq zwsBe7b>8h^7d!DsyWOCMWYI4p-+&AGbMcfIRwIbXDAQmczN9yg$F~t>C7u1sN7*KYhwvTTxk4^%{1i zChGFN{b}>-CXWss(!`VXQ9tF$Y3fR-YvzuzKbLkCW?N*>nQ@xq(305};ykEI=4zg@ zE6GEDg7gEdUaOczD3m;gfIVk{nl4Q~wLtfqsdlih5X}$PkB8b!@>r`^lYOPe%*xCy zQuTWcYhC+{H{UH{E01wxh@St-q3kY)G_*nDZwF3pO>G+N-dsT*-oE4q4+1PDTgvx; zodyNE(1vxh5wqxSx4GKs^kqRyxQ{8)`4F{x8ZBE7PyE+;8EVT4{ofiAYTP|eCxg=v zGu$q}Xas7+R^O)W|Kt9DyY=$yObd`bQ2MvdD4~7S$iJRLX@gFp-~6vnF_%7#?$)TH z|IRbQY}qbf46}s+k*!_q@s~JtXfr%fIcyGQ`)`B&x6XwA+o$#auPs=U|34$R_21sb z_|FmQG&hF!(4YS=hGbDoRe*o|*L?b;J?dq9{%!sah#ic;V8O} z&z{@ZlJo!*JSbd|m+|7D)^9*fm<+*SuYUk)(Ma@$II+!RS5*4a9BK15vhB3>)9y(Q zO{m5a*?GdJ^nC*j+E<%Letg=YJXq>#M3IDAEL$YyPmoM@Nq9&Yq%x%x6+)X;hlF?r z!2*7QbbJ?%@7OzA@U{sfc}R44ykkj>8n4m0K`#Z7KroaQWGfvC=KxZ5Lnr=mM5AIIj#Bo1WAk4Xhby7sB*T z<4RMVKegyt+bd*soewYeS=o^6Ra&=lw255Jvymc8nH6(vA{e{q7Xo}2bIf<)XX}(N zFZOf2Ubwkh$NJE#4&su(?)WMDy0Kz!Y1JfU(CYn)>l9|tT)59oJ!=kS^2E$)It3T&c+-Drk zn5TZ}u{1*aWCfs3O2ycpc=`$FRH?g?1umW!FF0H+NvU4hfU%2ZxwL1#@R19-$%EW_ zfI<+#{V(QDh44&H@hnA43~ZA{V_Y~>L$*&n_IHEI81X2$w{~su@Q0mNXusd-cvQmE zFYP#B)FPPw!Yti)-~v&iS)9evGubQ4L1g4lkXT(_luCMpcfbD`FTdLvo~xm0qX&dF+b4p-mP5=$wL=@|YX^>lr#K*f;k4;V<%f@eoGRIn~v&iGRd zX(~9W)M+h`Paiqc%x7DpFGr-B%s68Z*+Y-4{=mNW6}7x=qP`nUnRo9|nfDz8okTD_ z{xuU&!h>Q8)AnJrbq7gDcw=8-aU>ArGuM%9n`Iip&6RJ$J>ZMZ%9~%WB=VOo^MwaE zwLXntVf(<~YDCKkIagG1#+OUKlkv6n&v3GZNH|QF1q6b@ax*|NmbA=}i;l?Y!P=Vs zowUyJgli{iJlWfKiQpB?eN{Vc(2;Rh3paM(HS=RxCamk)JLXPymM)d&>8`T-wX{H2%C)B=7o` zl}q4b(ir;T)yBR4_2=yg-iAv2Cl^> z++*9b&09_-ShW9m=;4<}2$iMzg~c?!&cZX{gQrk^JJuK#ThNiltMnKZNC%qc4wqe) znpI~3himZbDz}qPs%Qq0H1&+~kOH79S|_#x#JP03cxPjKSr2)7#^LyW0N}%ryLQrA)+%KZ$pMZ_Xd83X`Wsphxss2q>t4?l(%y#K4%gw zdKH9jJsjgnY!`<%G1={wtSj%9))z7U+VYWQSlcsjUinqe`&vFuRos*5ELF~t5$r{N zW%4K$1d$H$+Rl0JVV54{|EpC#>tndamU}Gw@Gh~N(NlwH!fe9vfR@^%KF>!}@Y@lp zQV4Ip8 z@S;#c1IzTqL}kwt3kikHtr`J{K7dng9_--HX7k|d!0yNCGp%@`3Q*#Sv?mew!t>l6 zQfjX&Z8;)cC+AkCtTj2zBW!wVC|ViTEyphSJPp~00K@EB1y&fcZ09x7D7=j#PH(OK6f~_!*)- zRMqHl;d)sOMzp|$d6Iz%U-aYER7qT(2xDsgOiE~MsWQ)v+WiJPs$!#o9Y*hI4sj z--^o6ZRPevhU4FFg(1!s6&unfIYOYo)8+!R6z`4%LpA48<_xu* z)I+Xp(>~2f#pNY}({Y{x46TEDC&PKWHH#iXa$3p{HZrPF_%Oofl71<}$F>;X-R35Q z;bBD{uuY}3D;G67#)iC|G1YNqV}6xveY5tP$DMw$}de*+EM-v33cSwpDN1 z@2XGeZ?q)z&f#Y2e{{_coHH5~KAHE7&A^$bQ+G%k5M#rkz=e|anFN{*Kp+MXgvWof ze_?c^g%Esrd`QlzhrZU3@?Ka{IadDoP4QV^;NWbtB4R-}%X?5EPxPwy)ru2ty#=P6`C?$i0vv11TZ1g_k8 zCH)Rkc}IH4z2rmu-Vho>r+5FA&|E-A7$#=+*UYSxf(jlGd9K|`ew56k#S=Pu?GfXk zBn}1QPs*C)VVhiCURHQGRXf-s0kEV)t2N%ZOMg%-GLdh^@LuL{ zNO)qGs4(``S<1~lV{XfUx;1eiVP9G#h7F!QoGEps8@MV0A&e-!g_VkUQ!h@ZhcDx@ z;#t5dwx0(**>!Jnr|S7Pj310`wWI}=V^C(wAI(>T?&5;Wpr_Hc&?{CPks182GV~X% zOE%aYFkk_eDGU?;Uabh*JbT`7X?57(oD5|ulncD%;{cw#Y7HDlc{onJ-{6)KP zDdYP0nf0@`x>z?DD9$-ez=Cxk8$-Wv6t-nlofeqKp&-i}TPq~c#l-gPEbIXrXG#WW zJ$*hV#`^y4Pj`HrTN!yo92o7ubzM50d|sEeMaQ%*2AJI&alhN-q0q{Ehvg8X!rJAv z&ge6B*syE@ZlAqY8Qo`57k!rL#RfMU8(U5*P)*z5m&x5$R}x+Tvi+4bJ3 zZc+Xo^KM#?uL1JSz4s02kUSEVBOdj>qti`Pyag>q!iHA0P_)ocU_EV#D~NVwxZo|$ z3A!k0EN}{TQ^UEOo#jeRf!HAs9gly98=2v-x`r|@sK*A|79J2nNtRXxID?RnH6vPn z@pvAlot*0WeJJkfpCJ28ES2s9LfB^>BX^ZRd{<*c;E2I+DTNOrmjKPM$VuH#B`+%U zie7Ri|C*a-57;y=2ji}1>y7{xO$0Rvp0w)pQ1TX{E+^BYJTsIA@5zbVG5(<@nt$*f zsqoE{w`M6kz{D%|tX$BuoTFrGtHN9xE}TFglG-k zg|6*{Nf{>H`3`C&=qBqaIbD#7$OWNghul^El>x_D%Es1;6Sd)YHP-#b(Yv`vRe{OP z$1-T~L(Q#GGULy*AOwOf zk8qS|Qx;3M?w7$MT*(T`cJa;`G>5fA(p2@~I&p|ycZtpd$d+|qdYkiQ9TcDg%P+c zuFq!vnySUPDN)V*p0mrg*?vRC5Ub_VD}Rw+?tT;Ls>n-t78_>QpRd;lcHn9~Kh5}T z)sg_eU2BBizCas!3h6*hMBYl0ffyz;cSxzr2v3S~mYxBMW%ztx7vF|U!2M1wd$N0o zVF`0qF0L=Ala*m_W0w7YVi^9C@<9z0Zkls;6Lu5zn0A@h*+W%3gWnZQSxdJ7XnVq5 zb;~L^346lTcTO?^z7E9zNTe8=ZINIk@^R!PCQt<cdo_(GtJ(fY<1j21_KtX}12#5|qSYJwa82v;r5^o?KR0WoY%Z3X$v0~nzVz{^YU znmcqQF*0g|=#xt+AAjuhg*AOI=}MO^{9fvF&NBfv_>+5ODwr3jB8%-GAf6#-KM(5wH}z@=Q}Gnnp=(VUM|-S_Cka;C-CkIXN+^1&QM(7pA-xy=eD&%!?s3$ zjgnl|GgD!5jo)v!@~mMu_d(CtKdPK}_i~$|w`7o9(d#1F4W%>JgF{GG%?-czH!7;7 z`fd$>d_#V0?6KEX+pN;#Gv>SBu!eU%C(%qIqnjVgWUdv|XtGar+%U|&UgI!Vi@q-( z8y|?dxp8NZ-KbZ;3^zbJuGs^Cz8w4wo;bzxCtrETU#3t`=%>8wz|buqSbHjC=KA_q@r)dF5fD69#ih^w2_kc{ikM-=*CJ`p@iw*q z_;~r&z(yu_jan&3QC_xxe2W{9Y02|So>j0Zej{?!1r`7 zx{^0a7RVXw3i;dcY==;~4W{N4aE2P6LDvouq>i(Utc&{#Htu-V4 zaz1{RnZDr#kote^SjnE^b%qUgrP;fZPv02WyLT*Lr7%#Dx^vXKxmRXsWNzVRR=||1 zrt~z}Ebp#dfuRfY?dS03rqiwbd<%#(^8tFVRQ#nEas>N6_r>(>PHGJ*7A^6N{#=qj z=l#=(hpH#y_^w}mtb%9y0Z!{mv;Mjz>c)V31{q=}!@SN+y$brUaSW{wzYjt>|DyGu zNX?jebh2$9nv~0gbUHAkhD%g#&{r95Bb*QASg-wcrRm?Ud#{DrwBvq!0QUxc!Bif9 zJb57$G2QzAzl#-R8|Yy2rMFaB%KH0vW6&FDM#+ln|3X|=o|y6)g2(I2fUHyaV>@NXTs%9O%s`XpL z%w$T8yLV0~9I}@}&}Jm~!(_(&gwfEx90^7R{hV9+8aZe@_1HfA@_!J{fxNl_*;=>z zi@!W$+Xdu;LOD$70>4k-sUpfvA;d07z|szAG5o6a3;&Bx)X$NC!3a;i8VMJxOxuu< zSG4o)3}QDXlej@M4!Y?b=EiP{)>eb(TL$1xt5SV>LJLbFY7G&vq5ki;mP$5Rd-j{I z|NMkuT^tve>Zra8d;in!#cY7alZRDhch|87cPt7QEHfHn$&M0zI~;uUgq%wOwDyr6 z6@u97ilY|~9g=p8Y%e`>7g?Z;oqJJe`%%r?SMGZz0Q?TBrJzaHttDU{2$ zHB9J-+6w5(2Ta#|#iF89usQ6*2S+XjcYPPQt4&K;AMzEV@u;=)`;NfskcZ&9hJN$= zPmrr;jk8TvfJ2-ow>Vp=Y5~*gw~2S|4;???AGibq`IK|!uqV}0c3wPGZin~jJ!+vp zJ6aqrh-5G41m(;G@h@d1*eD{tL9^byEy5sePrrHc^?mo%#GsUQIiq0sUC`#WFiFAz zOzM4o3s98uF%i@X2@nK<>~^NO8AjT7@IU=_J>Jom1?y+Y-JoY@$xM`YpbFn(?W2bE zi_wO+2(Gk+RV@~L`ID434S$i89>CnRtbMmtZJvZo)06r6)*WVi9~PVlEtFuO^X)j| zW1RPh)=7I=s3#=;SfBZXTWXg;`w2%jVIqtx4FLRw9eb48MxyRyfFj`WVbb+AY2#MM zY409NwBnZ8QEofb8U+GgFoN0-4u_9anxcbzm?rOpITU+1EAZC z33Rr*oEepRt{ywcPV)+~=?i7akDW||r=W^-?CIbxS7P{vuLWn=3p^&>o{R^%*e!(qpanRVrbbgq6AHjwqZr7XJ(D*OC9N1hCs zxgod4n>1J_rU#fsxHu=A1#|iNkCzVyB^XDp0W8E&?#WIwBJNpYJ$X}*<8vuQBpEIGO(3pok_}cg$QIbm3uY=(Y2|sD_=^|fv)53Vom^`Rcp?Sv z@v~NfseH%E?(e@4eaq8o$u0Q!7mt;bQ*auzKn%g!>&UdtY$?6?R#7;xvMBiwZ@B5+ z<=zdzYGncRzulBk}?CYN(KG&8^6k*l3!8w-d=;EF4COAVHv4%BRz1qGNmv}JyfqqMx)j@L=O?e`J?kv@`sPmR)UxeHpC*gQK5a^WN z(D7jbb9oMY>>VYWS<`LBnI3xJhudx>HhC>cJ$~Pi^hu;Nqx6 z1C}Vi;lN*0l>~e+yiK}ihq2SB@tNxvP}ghXd`N{9}W!_rt z)~-yq7QXzSLrE7loRUv}Tz;=Uy6jMXvwNQ=!8lITuwASpE_V;Cd8{R zp~aOLO3<7dE zOVCcX{o>*xaTc%TzSQX1HW<7bu22M^;{>G3xi)XqU_SCV{7p-ZUd?1Eelhgxu`BY^ zzE(of(TcSF+3ORrb|^}}Gh;4TAO=_*a3{XMIJ3W*jdBjlZkGPOr}6H?cGDoWHnc2k z#=IqC%If7Hq)VB8IrX8hjyykN_7smu&{;-p> zt<7vGT)E~amSq<_;cQ#-_f`9RRobWYQEH~w{<67QMQClQkWC><=X)5<2nhu zgEqE_F3&DLDy~X9%S#{Zo|c)f<5|~g+E&D6EynQ7S@RL#ISnM-&bqV8Q`(o+@1bV6 z<`#H*_YTdpTyRsj4)gI5-v0?c5e~s_mufPLd`Yfuif?Z%h+1~!R%B5D<2*N*H5IW) zGk{XkcMT}{;Gp=e`#<#vRj21z(#40sejPIUgH|GTDe?JX;*4{E#&%))4|Mq(3}={s zSQ)yHGPhIr8vwhx>M(xXai-+n#DFye2sG^_gvqJrf61h^Y-dRJx1edBnwoV>J3P4@ zMhLnl=IKf;B!)!wCIQ^k@H3W^Ge_~v?%S7LMzuCL(me-*7{ZNryM#Ax>~b&!==m*>)e763ef9Ni;4DK@-6fbQ*FN* z%LRk1bh6*v-|yKPI0J8^2Cw0Fq2Is3K71Q3fWG}9P5A}4O*-_5fVv_s9)&*L%S(S| z#CMn^5AlhO(8u%Iygu)c^vbNa#kOS5qsOhRELh++G5BCZSQX~JvOD@YN<+fZy8G4O-tmsn)={dk7M_| zJ3}~__2yLk^I8MHk*xwMYRV9WFXhnO#t`)u(QGps2em1>tGC)6lpJBOAWt`Kt zt3+$`L&AEQ^V~R%uP*2byjD5x@Io5J!hYrO2G8y*?M^kB(N>WNhIz231)%km@}2nI z+RUF&fU=Xe3Y(slfA#+K+No9hDgQHVsSl!oeHV1Hb4Y<(#*n`hU$~}jRr8)wo6CVX zSlM|nzNhW}A*$jGL^D+}AYz;id0%+3GV0dga8NzecJ~3dU@0^F>hEmIuM;?XS6k z@LqgSh83h@%8x8~Q*pWKOcII836?n0V`Wih-& z+aCFoU*Rtzc6L7rE-wKvFp3FUu4jyfUyelGg~$J+)fC-b=ZkJuCZu^ff)GzrG+v5<$w>mw{OqV^A#9pA2D z$p}Ek7aJxN5NYt%zPcgx{DYz?;aEl-k5Ftphi3Ph_POa0{Tu2>vm<_lO8|L@|D?b#9Szmn%RTM9$p)tY7!aH1C$j$s zpf{OI-^ z*Mxv(s|AK1Fp>Zu>Jtw4t38jmj;!e5X|SMUBFTZALhUI&;oH*iP*T(mS(M8jbMwYt z@5!E%nZ+8%K7(Fa&w>#J-WFM35VE(Awkok6)-8Zbbgkf4esZZ>a6Zwuut)hj7L|?B zk8B-U4+s#^X7296^MD0l@<}-=-Go6E(fCwl7cm;7uP_Im?m)Qe`IFzVz{s@HqI&EA zZSC*pR$5AM^B9?;@&%riPh}C3V*&`M>x72{e(}*Z+04m$Q;0<2|HRa1E1<@ zV(lD`5=_#>Xm58Wluv!hPx>0=)xD&`!+#=Bb6Yo53h%|Up(|+EI7!=}9QyhFt^$I+ zs)HsjQ^r4L=E!j4KsrlcQqxWY_rq&mJ*}$Z6`dTKG}SBhxU)*5mZ=9d8FQ-#Va*K+ z7sR9ttY@dBX>PLsD;pm&BqH1p%ap-C57z14;t&v)LQ*lRlY~lOAEs(lqz0 z_HHQIlo4nUVGgxyIZEfyL*^&FeR{2ggLmFeuwLt^RVX$m+bJzrEl$7q#Ua+pt)LN} zsCO<{SwJIwAC1|nm3G-~80Q=I3Nv>N$b?O?rGDD=6a{h2TwU&Dne{C9^RS>II}jIM ztSFT%^PX{aKL4~YuIna}_UW$ck&n%sP4NW;l=|bxlWp4mf~4)b@sQc?M@{#OU+qNx zeEHp={j0RUSyY|tc^0z}kGLP>A4C(nu!o}b61yF4yzxXM+{1p&=rxcxm5B;i6)bkn zFBN(?c_RG8t0jNzw1ON>djy<-(-A68U*&9av9sy+3CF!#L=1yc3;J+t>y z#%gA^{OLQxc{-=hgmevLIJdY05z;y>MW&T&lQJEYl+&Eh1LU5-B3;4KS2%~>#)F0#e^pmU;H$y z?IRCYxJ@aga-++a9H!3f(>i}rrCS(vltey667yT=C^~rg2C-;)=vO^B!JFcvL2vnx zBh}-Fr88&h{{pI4fgJUaTjz%>Pn;2ZI1z`b6Irgc?sKwQeSK*1376R!fj4DU(Fhfh zc~Tb-1&(-fh}6)N>FWz?u?7g*d{2$c{0X{~UFG*){ml}}UN^L2i>7$L;><>i)zmip z<5ls>Ipa~J2In8Fhol|=8CGkfS{l0|?Yj?rO;TYt4hl1mo+Y)3HGn+`6na(5WMW{< zH#&MjJF;K13xJ(n0vXEzdg{gz+|!Fb>R zGf*4+F_?n@K-MxbxiolZ_{IJM8k{l;3jF(h=K}Y+K`qX;KqO#1m$6^#!#~e(C{6*Z z7QuvJH8>1SdjZmDU(;rOrWsBR{2TI3?hpdd$bt4W(Y639zTiJw_#))n zgn?}L&5CBUT29zn=yA}mFg`M5D)V`yY2Lw;a;7`8bMb@E-^$5_q5Fvu9#P!_W%` z2rRHIi`Uw=GCl<|P0%QQx@`g#bS7qsO6T^b1Q9%X=lS?1|JnBJ9H6HB8^241&x2lS zac_b=_#Nv1}I|bgG>QR5hql%l6=3?35zZ#&j*#+|fd61Lr^Qep~Ub zja?D~P?RN%sEWq#wc=s@fP5WEu*oT0HQ|jVG&;mKclLKfb=JknP^vWdw#xD|f$u-o z{oB0G+20X=^EK&C$!{GtgpSmJJXuC9+A(cWJnH8He!wVSFfO#jS7__fhZ0%8f~!cD zpVcD6yZnKzPsA6USy!hocK*6?QDb2P&;Hv%?$O8Np^V1Qv2D1xQoBkv0VB(!Ykrq} zc2q34<18Nvi=vj09hfLf#19N-pMz@h;my%|d%x9MhQ6%-3Zs?vm~cto#lWOHTwZ@- z^XHVjEf){nFk)G{2H|mh*Q6@p^vIFf>Ct(qhKC{u=@-iG_kZB3vahPw>EDf=`k-3y zCrBB37m{bpMdMeQSy3srS9Cv(5qk%$H;M zKGj(xPkzkEu=q|uE`)BmQe<~EUtBnVH4;6P3lXxzlU>p4b;?+WLV&G@n04$xz?J!R zE!g7B`itEIt$<%8W_M_?L`VXZ%b|DVbFfw-KFRu81|32a&cvW(w zJ3ek(#b@r#m1qPJ2dlPosdUP*V8j{4!nN#oh@0<&7v~S?k82f+MeNwl%{}K8W^x`4 z9tl?WqnF_)I;+XnwXS$%CKk1XLk`bgj9EBdJIfdO{QJv$o09q}ygWpNPD*!An^^rb ze5e-f5cuV_TJ-wN1L{UYn#L{WaAkA@qlA)Sa#u(8>Gq53dHQEzOEj~HK!G32Ft1p~ zA)lU~KMY?`wvc5_`*Rqydy|0WFucjlHm@2na6m8bp`KpgV4r;SHRDr^WFXrEHy%xc zbFoQr6Dq>|@=RPQe%Ng{S;9)N*5`L9%llQb9^&LObztu>t8Mtc&2{-v-23z3tfKDa z*sSDWhBVbDj1kO5Mn)8W5?ZgU$_oCekJsM1yZ}8@3t-L8z=h)*1=;oQ3RQLL8qMDn z;cV>^)~p-_F{_q7nIf(ckQS3bp;Ff$p^dg4$i0_2Gd0c0YHq?h+djkk!eyDdURgYy z%2LkiAJ2k#*!1t;VR*%)R4X+@(X=8I*7#43TN1E^4<#)OEf2$Luypp)l8t_kvq!;g zm6L-towxzXWy$YpTyCn2r^D4a+xROw?S&XxXd_LRtdq-_woX`VpiB(=)BAwJm`cfO*Ur8d&dm5f>kK1`bp z3O_eqA{d7?(DZ3;T*Eh4yFA*xTiFDBtpCk_^v{E5LeB_?>P{QPMUCFraA1lh2`Ax- zwzMHG0bU`O*%3~)&}TBe${s$v>R~&oD&n=h6NAS0hQRJ=2#!e+yO)PiJwiVw24SFU zyqRYZA0-pNKJb({ky9BwU*ywQCvA@Dmji{Xd3Eu&t2p#@T9vDS61BB1?8GGd21kbr z#bu@bifh6bs>9+E!dv8^E$VLY_OdX(N~vUYXtJY+tF<_e#e|hb4i;z0+M%gG_WX-GL0G)9W zD}zJ(+%!n&0+NehxfN?t80~=IgW3ygm@CKk4nGe3C3L8<>_e0Co~1XYWCKJ?q+NKL z$}+z8`%7!!U1Y^jj6DS&E{es*@vFC*W$XhS|i zpG{9NCPzh;N*-`Fy-@`SodLKcK<<5Yl`|`MREAo+2H^6^qByLUm`y+sJ=nqi^8A?& zx516&WbUL^LYw+~ero3zR@cUs!O1||oh?-Gqs2DeuiW*hwGym>Rbhjhdv<(fdr)l@ zu}N(Eo^;2oL40Hwv8-zOP=wvcFDFtuNx>LlFuB#URfzEGJ;d2jg3KMbU@Wv$e5p5~()Q1;?^S<7oL8i_I@-1`v@d z(lLlOBLKpq*jgT@;0zE+|4-1;1TcX&ws0tZ?=D;8b|r>YGiw8vAg8Ez4HcJTz>#f` z1!|F8$J4b%m)`6<#k%^18Gtj0!+);yU9x_$IcjO>D|PGCP@%4{U^YAz{3>SQUg?=P zlmQJ*m9b{>2QL5hEjMF>BR=PXb-F}#)z}|y{I#Z=d(4cka)bVUs;WYfzi+DMPg(he z3T&+$xww^6Ao@H-qTFATj&FVr>$5#`opxc?HwW9F>EIr>IQUFwmpD6+N{F&>GE1sD z_h$9iMzys-wawib*^wD{(;l7UO95Q%))6b47oOjX(0(sk!}_(r)@xOpWwIi>WZ*Jq zw75f~+mw~8&8%P0#%7f<(oP=g0*QdKYbejhWa&ShC7nNMUVE0V6O2e}Jcky$qL3FpK-@p_eGymVx@x27%d z60!pzEkFVl6JcSwOGvm0hvbf5$Q}uTi4XUqBn@TlXmbIQXO4tHulQ+)b#`MOfb<~f zde)O@{N<2JP|Y0r?=o)3RXIO_kpq z09NB22>%rjRk7W3bH4U2NX}ds_iaCyMTV;U^-+Yow1~EfNCb%5MHmT}`*l~|oJY9q zZ$k6`v5xocA=YL%H*Gr3{?@*zMfIKsUHd|OEfp!Mu({d5W zH31%bNSMa{J}s=teBa*&wM){y4nM%r{OMKe!6X#r&7YtdNVf%wBPs--s|y^Np`iQM zl&r`v9p}#CH4e3=;R(eh6Msyu7HIBmesDnAYc|7mvdr9?C-{j%te|=qfp(SW3e2kW zOSxM_43u6nnD@?5lh~3I1aDOf1&gYu&6C=Bj$h~XlO`0ol%+T^n#@AJP@^o|XBKQk_1Ji3c};CkZK-7#;<9D5WL3d^)71PezwFwr z>OVmju{sLtQuUDjNf^tr6eISx8pj?1Srs`{8Z;O z3|ZU?&QS+3Q{`AtQpa48>DP0PYpSfxEJ*;t4dz;lX;9l-3wnjHJK9rqR9QYbtx+;^ z=BT@^t&JBWuGNkhsmU>p`S5at;IZ~{YS)UfO;z^QmRp(s6lr2>A0kMYy;=uX4w{_$ z(%fHO*C$85e9Kr?3|j{{RbYo`NxnT<&bkzDbk5t2T-US8q#BtS9*! z#Z+&X0Ay1s6Yg4|@02 zS%EG2D<6GbFBJUp6R0IJX8bVRxp>(!ULk;nOS~oHTLMWLZGTrKLOt~TUQRyomujMhk0!Z2}@X7QkOp8Gn(jAW^ zxwj`Y*iVCXc|v5)osP-lF>PP?wtw|U9IMjOC7nVY4V8_x>syBhyUJ6m2DD1AEi(4tG#N4d#>*h!|8Vy1flU7W|M=E~PLx#S zu!>R%Ih3=}fe=C{Vil6)d_HYb(Lv-~4jTy}7CE0r%1F+K$!Rpq48zP0w)MTb-}ilg z?$7sge?Fhz_xIPfYp-3e>)Li*ujk=$z|`#lU@KR%B>mAAW&E%n-yVOx@5({-96N%m z%Q=C5&%(+2CMxUI;mVispcbNRFz~m=m)d=LIf=Cx9HcEOQK%;z{cOZKDJVf4ttjL^ zcF+N_V`o>z){DW#fv%x=f9eLAfU=mIDpr$`#sFY|?_2Jp&dcY5Yp8V6+Cn4`n}4d$ zY8Yw9YbXtzFq6wWj=mYXgRv;Y4``@;J2^8zVFa#InY@Q0rd~0((iPM)pD$QQ66w=x zbWTagx6AH8pw3lEtp{8<@f_&Ufgc~=K*T6GpzSHiOVK?C;F9YdZ&D*^3$p+A_VfQ` zi{9TLgO7#v8{C`q6pB*!+bYOE;YTzX!_%;H^P&qE`*zd@E53Qw4{ zK3Ml`q4n7j6tbfvz66V;RG~TBKRsb#$GG=r9VMLNAS9s9OV96U=mOS;C@_#NRj~)> zcpJ^D!e}mLBpyyRrGkKS0mjeK(x?6!(BH)+M@~awTkulF%Ts`#D(G*6(Mnl=|5*C5 zsTJ;@#pbWw=OsEZQnv{3j(@xi24pTlz-@CGQOP{ov)k?@T(g*V|BzoZ#n<1h=DhO?^dhiw7C>Ho zYt=lCmC@hre(z5744UCZD3@9<gZP=O@jaQhU04fUX-xuhheI0AWNePwk626;iPpIgPj&Dlp6{m@fx!}W<58jWxvOg!IWpg*v#3wb= z)|&^9FkWRm*Evm$mzfq)_`}DDM6Dk33-R9MEWw76!l=;QyKv;4cV& z>L&A$TEM>43t51e$KQ7V>5Gth@2GbJ68+V;MMD$(IZjA(c3TJyrP=NKkYX+BZ1=@ikA17`QB0LrVXKHzfV zonOz+_S{RpKN+n#?VlAFCS25Rtj5X%0J4W7VNjY#sz5+#e;D9~HNZ0#rRo^{5)`}9 z(6!GmXLsUjLjedxTy79uDp~FYT%=0O zc$#8`Aq;y27Tr;0A8kA6t(1Q8(_@phfb+Zadd0_7f~U`J50e&xY`Z|(mE=1WnittH zc{7kjuWm*aNHRk$mdJ(0gQX2DNe0_x;aXt}4KZbFsXMe~G2Xb#T!gEXBGqB|-uYE=otCd6`4zr+Jmv9*4!zK&YB-XI zUZoaUzdHS3WuzZr57fLL6)z*&yV{%-6{S$qF(8yg4v4sTR)f9UO)T~#?0l)KcU>4l zx=^j;4!z$!GN8tZtH2E|EeqM@IFv`5Grlz&?ek-or>%yjT!EnM*1mzKYV{6fFEMZ? zG$>6Gbvi{V*%*NekHil~nxEllj2O2>J26=jH8Z_4=sBAo*b*d~?x}7^pro)(&hyZS zHd&^BDK2aLyKm3=Pr9nyHl}~KVHfd( ztjfXBsRJ^)cUS85fHJZvr&7f|?SFvBJRC8~K6vcX6VCg<9wQd_K4kb|qx!GRl90J4 z6A$H6H6A*zgMdb9nGe{Xw+|z63M7#u6eb@B3hj{6Ljz{Q%{;-iJC#ZE_v*$HS2StL zHP{;D)#?55dq5#0nBAKUzsq{Td3*iB0SyH@D`$5-;M8qlT$Q%6u94fb{+)k^l)1_k za{ScBKFbf^YCN$RN>f60F`pN{2Yu^+#>F`?tzzf&w~cd6EIWQpH4Bsw)NGg*RGv zGIBatrIm3RVHQlVq0lJUB%?rpC{|N#@L6^rdPZ2beB9k?($%Rfh#@!lHP{p5!1q&GG|x%oTdCWAz%y74QT{JL6xy zyD9eC?KNkX;1ULBLMVwSCWQl})61@0Zx+(F`MSqoadwh}=o3&>4uf*%ixPhI*=5G z6S|1-OG zKkc%QK`)}TPTbK&z9<Nk9MnHO9BAb3yR@X;b9C;H+OV%K`tL&4e969>rHlwv&wXM3(pd>&TNv2HQnJM`L zMBIB91R$bw9c(@|Q`lhWoLK7I1Hhav|1@IJN3_2O-q>Ho%t)mYDQ+R(^ z(luhyIrq1D3p)3ROHZ!)!7R+V+*R;G4K&2LKuGT`@gFpu3V@~qIC^W5$geE`k-*l- z60&g4`;&)ycl^92;-lK*KO_`J%$i;WqgRVpb|LrekR263}!ux*jsX>2QQ zYr9)CDsnQ=hoOG9+iS+t_+sTPue-TH>9>Td^Ya; z_2=7^A09pOP6~H_oTm8oj6r0>Gi!b51&;V?@0W!}O?Thos>HfG5>OqP+cCQlj*H)d zqQqQp)SebQa3snfdw2r|)=Tw2pDq>q1{-j}+4}SCKna^?a=Q&igOAK$8}y-X#=aP) z(>~tbE~2MQM!`%#UH|xD{E&*5*Ra6263!nCjSUVL{>WR+_mUymv5$(fR?( z#ThuP8}+aTfuU)`cYfomxoxhJHxb6(8@2%i?PX=!Wq)#m)ybg!fIT;5_^{sDn$J(u zm$nRW`Ubt`h5~r37YL|U1~~%nS}I}ZoF5g5T@x^R2RC@Ic-%%tMiay!Vpuw1{@qR@ zsY_Ux()tLVP_*PansMEpG#BoD4+~A@t>8D!PIQ?L=N|v^!L#Z7?8b5;9$N@0gr9i7 zXpKY?xuN9nQ10d~;b@#e&G+OQ;AU7#w|_9)ojg6cL*K76$++d@6^Iz@`Jz^tq5nVy zd)TXY#iIteRW)d%E~dLH-d~*RQ6HPKcVEPmy~njpL7yM^EPnE=0pM8A@~7fa8%<3? zQ*7>-;%0CplKIS06jV(xBE`4hV6D% z8!XFgV$djobL07G)Dbx&#S|N@2T}pXo&}raMPTJjS>D*A5_ zF+^`+T~sm#{$^RKJTA#M%D9#9wpSiS^QhQvk}J!^Mf8+{S- z@Px<{o4Unc%IEGFW(72o4n$g99jMgaquB51Ieg8fQwF56UahVwT?pDPP;o5YgBcuG zi$3rzbW`bE$*E!?xLwP&lu~-c_Cv=nWMf=4r))}Y{=}D97Ua2L$(ajl(*?%U7EiNa zEp>r9I-kWXc|*Ul+FuyYPgum~o7^l=v^G&J02qy?ie0Qc@r!3#rf0N=;z$Y?-MqlE zygZKlTwVSop$agCX!hv3xXHMXMCJw$M-pP{O#=QFm z-zr|;`RTy(XxRI!T5D zA()WX8igG;Vr|bJ2jH7-48WVJ3eO(zGJR9gm8Rpi4vn=~WdnZPwhn*F35D;1_d)-{ zH55nb`~zk2zW^Qn0k?ph2p8@HuxI}pFe3ELGADs13KCZxMLcHh1pU9xBdn^6eGS|^ zPrR^b1Ee(dus@(*Uj8|HIwMEVlv^CJyB;0;Zf!pEVQEo_uPx!mzV=-c3)ZGjv)5>< zWILu{a>Wp#K+)r*jk331eEFk`tLzC5PGb00n6z4gO0H*!MyY3eIWs56To!3ZzAY{0i>fhL~PpD6~tVMEuz+)^mRXtwgtp18w+V~G*-&xd1~cB?kf z4&doW)XR-A7!C@g)c-LTtSY%5NlgTDYNUr8pzc7B;oJu!t6m2Ibq+A>*GVuiV5=B5 z4QvMS@YX}8I0DQ*fqbUwSLNc$aJwV}38M>!R~Ej;Z3Fe{3$|il2h5rfVGKkjvaRyE zV>a*V$dETN(d`J`sz>Nb#}TR($n~z_2=B;^GD50Bv1Nr5dSKAhgRxkT$`-fJYLd>} z#78Q5L@W>BVjjc>F5!j}Xj6aFL@Amd3eG~K7aIO@U;|GwC9cJ^UAuYw2q3p2-YS5# zfVWNf>O(K0#oV_G-+BmIw#;(QovRSAuxi||FMXi&L69Hwsbad`4L{=}LOV7V9xDX!Uj7rJcqQxiT>6^^OGta{R1hopgiyBGwck z_v(y-(J8&c143?DQD`M%ffhl?SPnTSj#AHD0Dp#gg!(WH__G_FQ_|tMtpLMm~ zWo2$<8X1m&;X43G)Xd$G8y6lkZkI@JGraLQT9vjtE9z{>^3&EI>+1E0jLl%D3^IPz zRJhm<-p#0*-CE{)`)TT(WCqHtk7$ev965MeXrOkQUj~%aCP2LtqsccL+?3CC=qjyR zAN(}3vx!{_>xv|FnKj0lUBn?Ihdf=QWbb61?S#~}8%RcnkaPmuEre4ykqLA>9Oryx z#-rW%=f*N&js-)-9>|3+0AfK-pD%R$Z~*&9e|4rFkYscPv@B;7ydRj_x>;Cb+Hjmg zxLEM9f zO4-(P8Dm^eZctPA2cBHWU6;-m3gdz;uY9xi*0sfLF8LU=EL@mP8{^QIM>eU2s3Lmz zjC`s0aSTU=L+2!9H6N#Fd6fnY+$b)lgF%p*TLGjjmsObV?F@4+0+5+poF?~Vd7j6Y z)r^~Qc#M&b_0#_o#NeD-XkIjLhwRpi=`5m{0N}z5e;0A3|I8WBqk)bcIm%y9k0CN; z{&k=DHD!hMg?f};>WPo46utiFUn!}s|8<+*UF9Ux-H`RHRd7NH=SIJxf?V~}kR;tV z0-)W}Z=5&g-bf}7d4ZtELQpH(ljfmBn0$luRN){X#j5!$O7lCAga^0P6x}Jh=tDeu z(_HsaCl}U1F)k@+0MfX7HqPA#)7>yE?j+BmaNOxIvpav(ebrz#$Mw<$7m1-LQ9#5S zr02SLasT?GDi754?D65i$i^*%TaRrQ>6Ftl)x(`t=)tM<4hms>^OpP>@*EUy5_IuI%|?6FFM3Rg=TprL<@ z%Rl{TE`iQ)q*JE0q#qG6lH~fOk(FZ6V^N6f9XRh4p(fgrxy6mR!?xr38{=g!@Z{!K zj+ETam^SlNfU&?B|IW+)7@(#8we&o$4RdEM*Zb7#^B~RBk;bIHM4Y7C>+JJ$NXn|6 z1M5W1lB@N0O`G|q)Dg6FVohd{EZ>Bm%{PT7~VV=}Sk3x`DIfXF0wbROWsv z%c=$g;1Pf9MBk0w>PG;ouYcLpp`BwJjDUD;uGZ3(#K6+Pds6iB)-AdG$ioVhKd%wr z1Q_^rD97xe6EYkSpcO8^n9u~B>!o!cr2p}`+a(q@XVJHxd!=AorWo=k2Mq1A^ay5E z14H*O?L~$NW1v5-fj7kj)?~s!hDfwtwpJhLVb02T-A`a>Asw)-+bzJvuN^@u>;JL9 z(~i*%kzwuA2Uk3aFV^K*vf^IY)*zd(-BA*P{3=9r2Vj*49-L_pE`!15^g-o+JopMl zK@wuR*8osvEd3Zzgl^@|Nsp)e2E76~R8+(+?})K}yr4-c1ckG={&8r!JM12UAc>!# zeMmiQhjS8m7NG!KVu(Bzjf@H>uF*l6U!as&(ooO_h0|nFps#z7NL3mn^Nb3sKz2ZG z`wwP>%O7S$NH4}7gzDK07f-t{nODRIR80&zj>K<(39W0euX`_*@0h!tC;5F+7uyRM z>}3MrC)UBRuaKzfIig}#6sRt=2k!ewydK43fEJ!HD%jFjz#6N0>IojwNn0KFbF32ITC& zX}4GWX}1#w@*0wE*~cnmMZE3cJkya(zJ4t7t^_lad)BxCLM4rtZ^Gj5KNx@X7)b0r z$o03~BsX>^Uf4k?sS2fg=w5KgsfwEx1 z5^wocStVK@CX1=bujG~8ow_#`m$I}m+>DuXT^Ajv`0QPDk&SztCYc3!T+4>8ZP?|> zmnf)_86KoT>I9-oxzJtFWsDjL&97ojb#BTTH(tp*d4k?C$%Dv-#Sur6Z-keOQ8Dkv z&8N_+H+s*RtlFs2!U*@Og7NUhuO+Z&CwjAGyK1ryYT%>`<9`whLZ%O!tBLf>ks4SI z17pj)isL__qx_Nd@fVJpS7MUh4502*YfSf!0kt}clv)e znFMqWJkQ&6n97c8_K5%3 zIgzeb)-A&;t(qL?cHYr=i?)=ChdM*PH<#V5J`9>%>sswOpzP@2H1_e+XmH?z6gMaJ zAUC$e);XOsMYv|=d(ubehuj=wR~r=b5u(5y>4?{Dd=6x?S*@+cfNio z38|Q_(`qTd2(^hmu%_iiu75Z3*s04an8`x6f-qyP=iQW*Q=YHigg?`blgN5ec3*@;uS!LU z3&eI7Z`JH9#9Cy9c{LbIt7%jd5XCm;Yuf|8taMXFY;;ARY@LdbITA*rQ@&Z*_U~vQ z^peP!4XRtvW|6_se?vd|f6Prq!G_dApv#*xVm%p#_&6CzPoMwsS8BTX8?^LC`(A8d zyPK=V+?8cKj0C#{jDsoRd4hPjMFRk4hOXnSm6tQ+_?*Ylb3A0~PBZ!YP2Oyz{R_fy zAK^0=v+$q}+lXBiN4^2f*eFaW<;5-P76z#GJAlxC`L&V%E+~FziMqiN-snE3J|z#l zuMZfsVHWO_B8h;*qr3rtAAz(6`=lMNAM$NwEr2t^jx3sxP2-nvl`!^_%gQncsu^FtJ zFe^Y)t0TAqfciJUx4%k8+~vOAT4O$;aj{|g3UT(V4xq`d^)}H}@KD4EG9^@gh zmh+kO;jb^{(YG=*W)^zSe*z%`?THVbf(1nP4i4wD(iN6fvnPF!M>KH)6t;i2X)<<_ zG{=DHR;{cc^s=#~;YxaM>kAS*i|n1qHGTQr@wpzpuA$yX6r{eD4q0sfs>m<&^H{Lq z!WHK@X^)8Wr>jMpcH2tl<-zyZqmI40LCDCqw|-^fpTNk~D3V_pXs+?@?(vJLfqnPC z6|3=O05>N-;`r*j|J6MKlaluw-^>(GuZ`LlsZHt_N>|yd8W#}N@0Gn}?EyM9PlNRi ztm)OwupCF-GoGv($gk3VrqLdH-2m09pZ7D+1pDax5CY>ECk)Wmy(+hA&c7cnueA?+ zV5n{VRd(k2yXj*BcsO+6zY7Ew_8$fh9)5a6*$i54`OMX2c_>aL{Bc_IIlHWn?!9y&7{hY(J~~ zH|Qt8DH*W~29(*s!jD~`Uy&-Xx1xVPk)x{tZ(Hsnr{UA)_u1bU36scvP+n^i<1XxA z@A@~`$Ff&|^Qo8+U}CBW2k?79sPi?8cQ^gd?vO2z5B)mY(%BNms>_l|7H}u^y&3>J zJj*$2&gyffvErl>*&A25u$}Zm^BvlK_q+3Fz>r&xN$Lm1m*S6ESEa(Gk}dTl{7N7yU2uCfQNa9PnpA(bE6bX2aIx935@4oCtaV_SIG%3|g zJb*q?e>d@2oAtJZ6fG(;u_|;ZF=`^-Sazq$gRP+EnlQK{Qn;ZikJ3MH8=rpf9!ov=j39_7)-g>(<;A-fKVQNuP%(7pE6ztx$&O$y2Nrr^90E32 zl3vNV547^hm3LbNN{dU`+;rAXfV5P&;D06}$bG!35rs}|=4yG9vHTdcw1>2x!M)S^ z9iW*goGd9wH99Qyd!RyDW+1kNGB^JtKjFnsPx4^EFcnenQH`BB{)X;Vl%os}_CdicR&5B1$(xI$wocCxf5{2L02zEpcZ|DHyx zMExd(x)8Q6Nky~pP+$QXPahq5YGZW@=ZZCNpf^$Hs!4~gF8rG){O@u0uC#;m^NSjG zC0W-58r+dPSEwI6K#4|vukp^MM03KT&syD71Y?Ah7V?%LZ&WFt{6Vnd*7 z{3DN@H@O#1s=oS>2ea;7XK%3g_Pnn%LTIA-&^cwC{1;YY#5>!9l* zH)e^u(&Uokc0+3Iv_A~80_e*GxPwEa#m+L_g3bCd_9AZ?xXm4(C5Y-1HsD6~4!+LCRVTZz{YIQ8C7by>(Lwiea zE(%ThE)w!V5@os$>(Ci}L3@9E@GR_0)X|5e`JTuH$UrkR#656p_Gu>gIXhaD6IrX1 zh_+NoP0t+X^jx)ZGf`jzfrr8r${|RLAjK41sQ4oDx;z4TFhqkCt>1A3k|lZgjrkT9 z8@bF3rHpKzZM)l)KWRz7m~~8pO|DI-fKN*It^Fq6u)azNWswG{qc12xR^DsbCdT0pppyA z;5GFcgO>p&yiQq^Hhc#XBRv~myMpY^)rkb@-u=6;lTNra>Nn`wfA~7F`?f3T!Kv54 zq%Q6vNQZ@N(icE0F#vAnUye)IsT@de`W8S91y~MYQNsV%*qtX*UV~@*_)Dwuf3!%4 zk`l+)_>k;{Ki$*9se^zqoFWjdDe>B)MBUY8>97yH$E*ZSejD@hZV&Uu6#;3l0r# zVSsabj8ewKp8aLK#Ezq7(Rc<@2|#(ybOuUq!+szoKWf*X1_V4&Kvw*2B61EO<_sGU#0Yk zfOI!0o5)5}S(NG)?%IVItDo`>E3vYxEhN5xcB5;;Qfr5zs&uyn#)QmDJRh&SBI^cP z*OYz|xQwa<8*mPkmR8&IuYdGe;oRRKjSl`Bt1D$;H@_pN6_`O({hd=1 zBd=XXEgtjQ0N5pG56H4^(A!h9wk8S2NJ*GKx$V_|RWJA#amzLeH=M!o{r{8B$$AQzP=@=s zFeN`YgxgGsL;sqHijP9>)gYt5J>Ug%o%+$xhV`1Nv<7&gr&2z)6Y3q+^QkL&{3MHZ z+2LJ9(^T2=!sIfdny&3F!nAj}_3m>6Tn0r_q7~NxwZn>Joltw#aGbYHwiQ zZmNBLnDO2@kZ(EooxK{SEPZ6eVy=K*wa2;bv>(ctWpVV{?&;~Ry%u^+{!b-}v(Fgz zP~du*gpn}^`k)`kE8ZCgk*T+XIbq{T8d?FuEuQG zrcAZ*$KpF4Mfc~LvD!$xt(Bk|)sMy3o0x<34pj~I9tF+lN8gwwU&DNDw|3@CuRhkx zp<53^qI~IPjk@B6TR5&aVXn}*rRVc4C3$v50{p3BO%2+#3PIY&j#EFl;v(a__5Nv3 zxSNa)J9xQ8j6Sn{yDQVPkOQGDT@JO)Og4MK2nYCjWq5npaAaGE2u{ByHEK`f8N4U% zsCeUtn2_UFEoB}JDu$zw&pG$I^E1|(wVt@lU6jkasF3HjQvdqp*SpzOCZ_wI6`5Fs zo(7apZ2^Hl%ujraC=>rTo`JS z!8kSaL%y~ww7aX{X+^~M(W6BWX&uw)%%3!j`Nn1Nu#l}9lsap}ETS0^S972{2mVGe zWCK8#WY^KpWfcv1{v;2=DWwFzA@aC`dg%r{I#SzVMb-O~ifgiTj!^9~fs-&;mgN3` z>UxiWq(qwAmnqrB_mcL`q z-2=J3rXS&6mc#;CMvE8F4#kzM{08m2l=L_;i!00=n57_F5e~a-W?MStrJtblkS3Zs z_(|{1oQCj*7f!BQ-;IWmrFs?sHzf))#LwzoW?6Re4p9!U)%ad0O1hkq8Fj|=4ftSf zfC2d|@X45^cp14>O>Ssm=KxUr2g9>h_`JN1&a}eIkVQb#6``4zG-=1%hMw1Mrc(JF zo`+uU;P`R?@fQ1AC=cKR7o__qH%{U{wZLBMAW~R5c;2@3xqF8qJ0@-!s(ja{A0g5K zYKEx7_O+gxLoxTBq%9YBh%R)yz5Fuw>hw*_9TD5u9g z;w|qyxQH&sF7g?ZIvIyxt56zG)KBlvQReE}HrIILsATVBpFgtttt)!&1 zARDL^OTO${L2_OTWDv1f}) z6+}oj2&(oJ$l~R#=U%`M!){DXWjwR=J-IN-c0mFs3uKFXg|)1EEQZ|Zt$9CLJm{)X z@FbVi3acv3-%R*rv4r!}rZ2W06(zZA@3aV?KwTi`ko~ z)#74(vtSt}`vz{-?D%zZDEB!A#@c%M{qsk~gGt`@H?=DmvV=~ful5+tXfNcbCJAf8 zZ2&ywfbXKlV!h*eqh+Lcn+))KdP57hWwznonX+kD`4bLICAY|;CXrVM0AD&DcDYYh z`os|Wyo>yPpd$$-xJo4E^-dk@2+|XU4bT?-2%U;18GU*| zNC)a>yS3FT9wIri_K(PX=tS6eVEEt6@Gil4A_{iANI+4nc@NYs!Ic{k^$JiOEEI~5 zo%H2=gZ;0x1CR%dX#_*ZT9j>6QO~Z3B`gOv!W}=K+$*$ z$bE$@c3>eraE^&wm(8#wgv9$uohiE+zE8qgDqPX$6xT^NkAmk!Wt`%eok7{(xOGSe zJXhCSXvqsZEG>PoU&#S*^=%Oh+;b5=7}8Xkv^SKMf}yovR{HBakEp~xr}HmVKy!$+ zywS^Ed4RScfASv<=i3n$h{lQzU8a<-xgcj@^KAG@*c*+`2fqeZ1}~|GyklQKDIRr% z0TE||uYb6#{Zk%T#HaP%X*G$gxRqzW9Y1kEe#o`FU2|mzAEfhPR-8+c@p!l=^5Q_Q z+!Enkg#+A<7w$d05;|YtA9{)*jUG&(5sJpNl`cmA3+6uJ)Q9TqkctnxgN0PWB~j>D z)&h9NGU7tEWpqpKi)o@}hnT>lp>Ve>K$3i2*ZZr;1ZCU0K{V8-x6BU))b;G6Ph_Pw zPzl}?Csew&ShMWp<=x81vdce+oepWiT{9q$Yv@hVX5n*l6kae(5_a9Jrj#+&%_bCP zj2F!3W4>w1XojRGcAoy(V<8J!F_PSS<;Z-jP_I)BAg}`a5&OvO{uC9+q6wX)=kfm9 zwri0wC30iZYBcb6-&VfewA5aKW*?xiD3yWrX6j{Q$}-;-^#u$k5B6dvk#NPiQrIt@ zO~GgTmD__)o)l7kM8;F7y`p}htRONCi^R^UelfH!yf=bfJ=BZ9hW6F88@kx6uU=2i zIx9&h$Ja`?4GxE^699LFnMzgm)$7SEm6~dXiKy~80N9&sH{FGm}Sj+9MY$l(dkh;q!265=Hrp|AWO~VG+ z3%#nE#;>T&(ybcz;ssko-mFKcTZr?YOg-z6a~p9dKIhR8KM|q>u6dDe)&TD{`VHC! z6m_d={LiHqHdHvhq*lMIz;jS1O4rQj9nHPI(3M^>anSDVl4%QoW%IcBuaq+kpu37) z4Vw%NREyd5v!TetgY|&X?>X)?5=1zNYad}tGit2Vi+bgB;s94=8Z)Tp)HogB4F_S?om`82kTcL&IGk~LHqEZQTRK?&l%yMwO;C6tx*v|cznYuO`!wKR+ediW&g&Z0-}C#emN zm`Psb)~)G?P@?H%V9WjPMQgU%_8X^z=y-F zg!cIfJcI&Lq}ZCP*Z5_ZfKuzp7E&B(AWGYUKO~xTso`en(C2MuL3-G0p)25J=JifT z1RU>-YC1Xm(u@ZScBGa~O4J}4zD{X24$D59$mLgVeK_l%7;Ox@nzefa2R1z4#KG=~ zsNdl9glXI#the)QAit{tVhCY>+qPfQxt{NJQ!JAYr5PLvmnMFgjW^eq8%sY%W&q(* zHTFbcu{)B9+itwfNp4~X=M zzuuiJ5dNR+j z++(rP3P7J1frSVM6oJb;Tgl5z5@EgPKIDhuk)-)moJi7-QP4IExdaUH#qJW@r-E9k zQtwj7!{rZ=R;$ZBL3L>(*Pp!_k9)|kjA|0b(k1&sHbP#W!QAXCeD`|I=0j>()-5eoPqGLz4imf{bY;Elnd2^cfuI zl8nyf>bCoKg8~>R@u82)-+rXcUb6GKn-xsmSiVf^6gA)2}9OlOTgRnhPp0 zEzs-=(Wb!=f_+HuHGd-})r;(j(iVgfC&E}BJfs00rF#l@_pL{2X)M-a#tcG+Gn@!t=d6jI&j0AMel;19;M z@1NTFMD+;{Y|t~MNx`i{Ux6%{mcibhzb?673YV?5*>&`E<7*X1#BF}_Anx3m!ctJ= zpl+L)ZsjMj=I?^ulM^`FiCWg}Bq>0?2cD&NT0d;vf1pw@W(3<<5M6 z@&dGE^cW%Hux&juRz5~t**Gy)<75`7Sp9R})7xjk^Jl?`!7l`XKz>28tN7Viez_=> zR6p~Dw|KuUF-dM7;}f89_ZyV;8>9s){O`K17k-06c+ie(ko%w$_95sT*4H_y-}X#F!F?8cV<50*gNf3O6!s6u4;m^EL|R9GCT4A$#yUzr(n zLBXS5G|igNsvDIZN+P3?cjC4CkKe;jEbW+>FZi4WPUj2X>(4`?bMi6p?}U2omLedh*wDX$c*)4-wi8IV9l*?cgL4amQJzX zBr^>iB)R2l@eKgTJWd%TxvD8H>pxNzf6Dy3Iwy0yCNgNFL5KZTa+P5-=h9HQRbB5< z;o#wMuI>kcyggXLzt&~s+_L&iDKY+HQoZge+Uf_E>&6a=dZV)FsLE5)v;E z%Tc=csP<4aQ+@Zl4?gH!J`5r2*_tzXNv=1p)}S`c%?pPojVPLcPW5kihX0J9cZ`GsqNt@;{{=xW6!r_MVyOE$ z8NWsz-w-%w`4W*0gpHKdZQzG*$7>O8>?oitT|7(Uz#R2;Ql7SYy=~DArE{9Fho67T zccCKF>0J0l6HmF)Qm*?TZ6P>J=%wq24{olKQAS-!T5=4Fy)m}9Jm2a_gaET1La$;*~pzuZ4Df9W(prv6Y>(InKNtOvSVMwlTd;YC>>X{QhyT zg+DPEPy_O7@JPYqg2pJRH-yKG5OETqO}(INB=d(~s|Nx??kJr2;uAIv#sSMqAVhrWJ|rEVJ80JM zQ9?av98b{HY}Lgo`Rwl9%&!{g>}0?Tglm3hiYPhjvE17$?+tD^3TeIUTOf=<2=)u1=29oOT-} z#y7tov5F{Sz)5$f>)t1iTXZAgq|Y?;A*;g2ir0-_gZ>$8V}-Gh+7(DiejL zj?2>eP~V7gSs%`^gCGC=_$dM|DW6{VcG~xbkyLtBz8LY2B5FPU;KyUXL1dz7IBdpZ z%%J6ZZSn%?8IZ;6SZrIfy@)p1WlC;(V>2Q@i%rb{Rc+GH&_g$q>vL z8|&+C7QAO-cc&M1{mqR-qmDi;)9I(DLCVoj_Q#y#22`2$LusGYQfAZeZu5+9!cXmO z6Mjj&)II?}E!TSYY*YB&oZG0!Nd^pz(XOX9sgqUQATkASB~ig^Ciax-NjsQ^B~ou4 z5*%%z#B26mkU!Ha^r>~V}NIPvwaRDHvn{WjeZQ~SlWswC|xESOT4Go>~mW|f6Fe89a^ zQ-k7#)n*4dg+bvLw`3!f2bSJfq2^(gk!dH6yx)28_1jA#Ak&BzFYTxaO^FnI1;W34 z4!#%bA`BU21~IIw>RxuQzfV12lH}7UI-Yc0`bI_jp~NJ^J%&01rdT>RavE`$g50C< zkh+rd>X*DkMbViYpDT-oXLS>F!s6H)>&)*v&G8Q?zzsu*(pkU_TyNl&BG(7L>PfF2 z$ZyX(&^UKq{DSlokh#`ICk1y04topVW1Z?zT8W4ptWF>4>1;~)*s(r#qu$Fp>^*8S z5(WrTA({I(Z?Bq{=Uxu?4}O?b>014Ef5^@}jf+D`LSPxby((9Z-lMYfmdZ-Ny^Ns$&{>KX`ocGoIhRS~PCwd%E?lu~PEGOlDFr=#WB>U9iC%T07z% zb60Lihzxl^M*fiaQPFHvNmiNIvZn4!Y5HJH-f}@YdWHocH;(AtMz`4ZeTZDUeLE>g zh*&6~ixbdXb{vi4yJK$k($0kE$#Tb2&$pd4uXI+xxHZRbZcJ<>>|m9k9%nt zJ3yJ=8qEZ_f%0a%j^`bjJTaMbTS@jd%Kx>th^B7ws#tOAad$!&eTqTw8dL50we(Wp zaRJEy7yQoDFw?f4RkB#GD_o9sh5kf;f$mdl{orBe^Hs)GsWz7t($VE(>awai+1jar z3E`A4j4l0S=)fwdUld>msj}j)Hg!H!uk01h^p%w^zOlFA*^!7^*=sV@tLaNctA3lC znq{&T7K(S1jC`I8AskEQ(iCMSpPs(yf8xiRR*5J*2)Djjlqt|!v$t&qT_M`$FEl|q ztqGb=Ihw+9$^9O0AZ8V0_Kuu?s$I!VHX)|*)U+pXHJiu(3Qq7pdROdGl)7-E518en zzE?s>f9TY9^zP@dL&F@3db4L)K@vi9=9cr8o;M44b8{mA z5-p?)v+Yt^?`hEvi5Nk?1g|JYhm=4ckNlu_Kwn=#xLSSpls^}aw6$j| za`!H)TWp<6{p$9r{B`hI2u7kldGRC8yR&lJ=$>ErgyV*#Bb{6}V|FQz z;|Mg$>^v3R!eOU}`IYWL5)gkwExHZw1r}}r-|3S3WtixzhrEog zX`^$=oj}?l(08y|YT(KHvrme4;qQyL^jZ$`Aar6Bcvf0|;3MAY#!>lCopA*N%2{rY z;@|elq5ltM?;g+e|NsAQrch3u2~iZG6P42#^>RumCFNL2lEW~^F&jGPER@rf9E;^R zhZu>GG8vZRoQGi!8#CMbJ$t`D@89S1{(e61@B8!pugk?Om+kp{Jn#40{dT|J?1eL? zExF89o^JTh+5HAWKi^k8pH5IKKY3A=rRu*kLb^)=K{86t#0?I5c=Lmy19w#sXH#E# zv#goH!B(7l5f~E{15AC5LBbH{dHyi#X;JWf@4v}S_7e*gn6nT%sy#E?G$#? zg;7h>-H0Bt>fNnveg+PH*51_4eOG7RU+QB1piR=JQ;#4-Cvz$IbztsM@At zPXKAVht^;?=+&OnEf9Q^VVhN0l(^|#o%UwZn^Ax*lI)Fk309|fX?g~hq*jwT^!%B0 zt}0p3Q%!rT)oQQ&dSutqds%luNZcD~osoqR=3u9HJ=*ym%HO;JCb0&PB-U#44P+jC z0E>1nDJ`2=f#gsA%+y45sz@nBggn>NBo@HN%a&X2%XJa2V`V z63;glH~AT*uPvCIgsLS%`;OOt7y(w*T#9~L?>pAtp%25+QSs1a>Ql*i?|n;u8MVvc zZ^ukR#{!zT#O=k_mWSxplqLGwP~guSAFfzw!JYA*Q|ogZ9*Exj@!^EK5jkah`P>lvhWb}o-Nlfn%S z|JeH0=^d~cI$y;_X3P6|9<4ph^Xrc9=EG7RAVmuBEW+l#LaBf4iGARaaH4hUjMm6~ z5dSX5;PuaN@H@u=!?jEA%y*d!0>(o}NKMRIoG=a0%XBX;V=t!|eu{lRP0x$(omxp&bKJ+D?0q@MwtMdmoBWT%;MGHK6tknogbiO)g(C#P8 z!U;GDC`IcVPq;GU&jaY8$2@O$N#6T3Y_2_M*d`gXAL`eB^hFWP$0-9|!dmP@TYOtz zR;7ICVkX%IRiKxFm>@`-!HE#G`6tI;3(ASBuDHw5RX2|d=?eqFr5;w6vsFWk+mmyx=9S*L- z5vE7CG;7(9$4gX4J^k)=JONer)X?J$uK1cNAewB<1$Vr5ai;cDaqYA(eHqr-9DRAN zL8pspNf*{|x%9d6ll0JZ`Lll!6GYmtwT12W>lLpZ^LN9Cm1ZFIvm0HODYYr~efu2L z2Io5&gB{+rYp0$oJfYg8Yx{lDbCsQP*Wvk*U?~rQYU)tcvEe=FVAd!$i?8zW14;#L{`)mMLsdi1{2R#}XXd=8BR>3;g% z&k1UIxnP&Bu(onx+ji87 zIh<0>@#r6oz%HYcYb;bI4?4Q&*3{mO_^>$B^D6Hf#UTAb^&G`H2C&5p{HO#^7UuOI z1V6wxmz^9Il$Mrhbc~6UIt?8QY-gYhd?@oD8Ox_T7oB6U+fZ3|m0$gn@< zTzAvJoWeAE|4`!Ks#cj4bA;$`NL0jHRRTehCqTr8-=G_?5kRX)UjnViKiOkX^w8+~ z{Mzx!aCDA>=ouHsz&78H!bdNmDSuTW@nrNWDjZA;a65f&wRWaDtr|a-p?eW^FRnB{sqg@+34u^uCKdI95a!u zN#)GUFo5Q%|1|bogU(g&rD?+~P$EWl*TB;vVCsJm)RQ)0{ru>a{B<{wuOKe64Ghs1 z3`1liF@}PYT%#3PQG30)7Rl}5Es4>YG++>|t>2s@n&r}ZuoMvN<$!*BEg1Pv%5_x;YRRIr4LQ| z64m+*r**n?Xx%I_Or8FX1Ptlwrv)&tjUC??8(Gg z-wCSbPIGNp_NSk!zDk-O8O>P2PR@Ibgj?6t#Hiq%HH4%f`d?^r z+cnz|F{_^1V~R3cqt3zHMYapY>f5l3&BxubDtS|t3t7p8+%>0xIPC73$XB|Nl`9>y zdNH!@`5{ZF52hGv&w3`=r9O{PIvO5Qm#1~;L0)NkmD>iBJpFdAQI_58IkjoiBK;+M zfCk!aekWT)=vv%+grjYFhgQJI=juitY>XRU@{PSFA?0ryvp=rm17n0`eLcO8HQctM z+*h+h2&}4gI-!^yLw=?l`|@g#r>95b%knu2 z`EAyCJ+ZN`vR=7m6JI^OM)~%fJ7Ag*eILF;RSLu%h+Ux$gv)<@-QIYM{OwHT9FSfk z7H%nh=Yd7vjS|ZN`z=MLb*_sjaJEp*Rp7?r*5gL;VI7;8nyFCw}}@N>+bOV>cjK z%9|0u7L<3;eBcq2`=lZum1IQ49*kWlc18HqF4}c*0R`&d4G!x^-QtC&utPBQNx;%Y zzt}56e9mZb4|5%ki7)U9Lr{CWw}oXPXJ|4VP{7rk@BrLD$oo+~zEk2Y1UAli0RoN? z5zA#+Xf!o=u6Zwb8}S*=$Al8BI1+rSA7qQ~88ao?+pwcpG%uY7tliWj*r%G(1|cmIFZ`v0VV9 zV|-FakVz!a`RG0AdB>``hJ#PPYF#xsBH9dSx-kJPeimx8@ZntPw25Q6bv1#)S~Jz0 z6mz$M?_@ul!yTCyc{hLUW?hn$OS6Z1|GnEw%8R>Nn-mhPgq6=NF6W<^&a_CJ%zSm{ z;@RdpT!TpeWAzL0u1E7~^@CeCxfMjd9Pkhc-@Rh}*wwP{>-}MoudkN(_5jk&G2g0N318 z>N5WA374s$gFeqEUk;sG1cdjr>jK7VGKo*R&NR0fg1p;h({IL@_A895of{mywO&V_ zhG%#!G*8Umo-eK(va#B5>EVVUEAY#NujrS~5{cs#mAwZZ+`IkG`J#NC8E9;uG^B<@ zNwF5$;qPy**7y?Mg4QGx-G6=bK6>f>RL{~7cPo5jn10gW9M_}Q=W5ouXDuLuiyD5J zXkn13-=_GP7j&pxXM_SfYbC-`_F~}4kGE6L$F%)8IV^c*F1b58TZd?0*wJ>$A{RpY zQ2W^0vK{tC^)b67>mSrL%BqXs{OvW}UuN1y9m@Ifc_mb) z`z_@*!n26&E(-&tr@Yr8($CUtax(?MV=+mZ6hG~bkAM7}z-)9`SN3(xUn_eM8GUrz zX67Pkao?oHn;tlRbm_t+?3s9Fr3B5))ld@R2IBP~sSUl09eFiy=ga38e1M7p*fK+* zZf2j}t}RffawEVAFc3S{@eTbdLK@f22BDt-*S2z@HfeN0LQuePQZYOIHBGw)pI73E z8pWcrQkya?&Gl^?&T4pnzV+c&SC6R@#vLOS+05)ZttT3|Z?gaGj*{wB{2b4F3dz1q zJMg&tvAI%7g(rjJl*T}L zxUQ9(v#Y(C>0zUm{Ko7?(*m~8BqN3kdEk9EqyF`k$ge(4eq>COXwZ^2trQu*cR^;o zc!e9cYEkH-smFjd_=~iS8u_1zGLmX$817#LQWl>F`D?ZoQ@KBCW<0>kKI8;h0i>$} zQ%EopBcaZYUWsqTO1ka#JfNkS5V9s6e<%|#wZ5*8xM=q??nG-o-io%%ed34xx5J{= z8ig{3f{=EF$mq6+M;upWD7oc=)uQqsATp7b_0y$iPDxl^E6`CH9K1Agf{fN%s#J8@ z^Dq{cq1GPpF%-n484O74V?f~8*=vtQoKA!dxzJa8zR^x9YJ8o5FDf6ynowP^KK+}l z1tvjq|k7J-Rm&b;6CNik%$PwW5(UD25Kv!$~qa^L=#!RcJ21L zp_)Ul^S)JWhE_HP^BY70JkFKirm!@)bCwIjS>NSG-vNy;)10Z?Lnu>c)3UtIeT@LL z)7w242w6K!exy$Jv`ocutG6oYZ*8lrC2yLY|5%*E&ABJ8uRYo8$B5IkY|{@kCn`!?5A9WwXHPujXa_DHoN7D?nS& z8|ob2@z1PW6c=mLCjB_vC2nHmgPA%74VsC&Il2zW^Wr8loI@jPJrCR7rL^(car>V!|7ba2TI^v-Y=Kyb-bxF zpOW=$twN@la=N_jh>KSpm=v6~6@}}7x|w-LPNt4*m9YRAv8I1Y52if}98uZ(CO_sj z#@r`s3wxE4qn(?oUl)p-;~zCiIsPyS?dgLverLzW6(xJGDB4v^-OAJHGQ0bpHhfU_ z+8;^nH%KGzarA5*JOYcRHNJ!@Nu^; z0FU?Jf=${S{m0eHJKo&7ky%D*cDgs);b64k|3n-7d&yJbq1K{nekl03peqirR{|Lj zL+p&N9)fq}E4_?mLcLsvhPXP~$1tZ(#eTk?bBbd)S*R{ipqwosD`^Des2y7odf{{T ze!}pA>P3O?O{WY-bfAZ7fou9sk;Pg3NwRyWYkja|bo@2GG043eYQ$--HUP{?PeWgBP9<5w3K22fjoH{V->5n-jF&qoKE zr^}syLT+qv+BGVQ{a20RN)u_5BO8Xi+W#j}xPU7NJjnslRXX2g0gJvGPwKt`vC2Oa zgPzPK;N$@{2ITfbIeiPrSfAx;ozLC(B?F=mzA_EiZLP8!Fwhs4%C#xR+e?%_GvLlV z2Re=F{M4~G@YCEn`J~}y@cucLGPR7k{#L0PFbHcD2%|E9Wl8VZN;t)zm$mUM!^*(? z^_OeaT-QTyni)eBeUEedSyVhajmn-qegS)q313=cIq8-p&eTO;rkuRrwQ5e&(Sfpx zbzMC@@T_TGsAA=^^6OKIt1DPdA?-HZRhenx7PS?l^O07^_)|IXV0-F!hSdl23>a%j zc|*wyCgQ%@9dZ%QI#4Xmss%Jr=fQ=+eo9Xm7&HXas|m}4JLReJ_7#~k;WyC%ge73z zXpYKkW-S;(i!pjwkz>bf5FLyJgEw651${tdFZv@8(a4=Xx}3>sNtHC&Av|Cuuh=X8N*(03HEStEO7TOGg5QGvwv)DeidRdCp zc=BE_%V_(@GONp*}sdp#P&s*t%iOG1}b`xEH|1(5`UhubFQAYD3@`4-iThs{Mo9?2+b%Y5}q zrAOxNjV=UPM;eIU)i2-Xt9`wZQA**#EI1n+WXQ)I1@3#ZstG>;C4&0>d#~4`yGgn% zC|7^G&Xns~hg6r4?2g5E>2|1(1RPt}dv5L5COO5l!!y)t9i7kf$xbWpUv^I1woo4W zRO$p(dQf{RGoq?0dE}UH(Ivg}jqW-V^m|o=2i>AR(5cKZUn_q!$;dcqK%{!Z8*n@i zPZjPO;p$YcaVfepPYD3MIgS*B}4P!c4ztdcpxmI0*zf& zfgJ7HDZe(i1S47Xy+3COCv+HKVW(d2zNoGs_sd||kHvS%7Q4S~x~594Lv~%88?w=+ z3y`i!^J06nmR90^0h%a=!7!Ar1pP1m@Y`D73=h{R#6(F5N`@%Jr`|2plPU(SE{L$v!qQc`T(YnmdUP-Iv`cIW$_Tw$9j`1+kf%)AJVs{Rq%D9BX z0;Zp2RcyC4K?KFkz$U^#=;i9=q?;dhRWAE=g5z5r@Bt6%X^XpvRqPg>3+BH9NqdI&pIQTeP>7gWAZ?30OA_xpvp!Hp z)R%DW3S}&XbbQQR&h08%>Fg!_ox?HNY`KGq%ffryX>pZ9v2!Hy78p=1fn=RWyNj*A zee^#=R*r7ILd>@U%^N~3S`QrbYE)QL5Wb^vTtTeZ_m|fq2=J|L-+6mkTNvAt&)b3* z&8O}hFZ!74arxMVXTsaWx9P4!Zy7DVvubH9RT`ras2zXZACewkB6oN zz+Gk?W4n?up&fI49S?J5N_9NE{(e?H?kK`|N8;#uW7fH~{t=i}YvJwXwZBRf9hOC0 zR|TxBz%q3aIjh?987Yfl5%PM3x~;0bjtENLVgZ_NJ!g=*;QjebpmJ*4FS%dyPX(iD zN#5_1OrdJw!ATm9@$1;})rN*Jr0jay#|NngzQs@PYuVM_)MaXDs<2x;Y>g~k z{hrG^=bT*sBmzXQcvVOdQjF7OVmm3T%FA>H= zDt}$&lV(!>@_J;`fsyfO-i*_1R}wc7NbvKt%dG;MC>O=AMOHXXX9iZ5Ptp=pK;I6JoUn^h@>o=%-@r#LHkKJ4YXUMvqu|>j; zm1(+@aXIqwm)$v&+9eXk^=g|ztAw;kpRY-CqqpSN_>>8>_1X(CfVXVw1JvSn^jhH@!n25T-% zPW)+MNQ8v=S)0%c=;Ih@W>$`Xk-m_=QCC<%Q82;I+lxV6an2|m(I`3?SL~s`wOF!< zf|ktD5Ho~bpEVv{J1Y}(AlzF@KI{`MK7v&?M`qh@&}nP)foO?0`#Boy=EO8*MVTiW zRuVgtLTGs8w|qM1Cyb5gZmxXK=o?zrU%w$*Xk?@779x6nO4cv#b8L?IFUY?Cz-3G{ z8f55KOUqMTN**9&i7#QjIJx*44NXdS*a{G}0BqIJ9^VtwmIbpR>_e~D$Fj3#A-y}* zUw*=Vevd*>HhH=`K$C%DJIs%rlDaEmP_RtrOs+6NBmkNeLEDPmJP3k~%h&QJBTRt@ z0PK?wtPV~3gQ1Q@@Y=ZX04@4ANSH_3kXeOI8VYe0H(UglGkD z(}DafG-#(hkT3A%H|V*1KL)&wtpjdmwF4#1;b^_=afRCb^^Y!49r z-d54*Yj}#P-9ZPq7bmh~VcAM)P)RF)y{$OxV^Cm5)Ujr+%7GYg6HhlHG)Ln2N-$@Q zp14Sn{~kaqX#A6FA^Kfgc{6}m$YROzfKY4YdBUZ?&5^6_eZya=;B`;JvOrbreZaoei{rpAwEJjG!`Tt37{eYp};`OYm z3#aCTIZ@x0YDcZW(o)2W-EpC`f@{=tZ+wjuzAgrTTd18VCxJ(p{YfGM<(%yAYKioU zf6bEnaV)j8(|8EqUUmF@3gFmxaG43*-%sfox~nmL(-pK^JVaU%Clte^Rr|ffN!357 zWK?j^I-8=PbyYXlTF+;bTw3x8eX&{l0-cVhUw`~9DYSVk6VU|S*x)|6`2uC{qF2_b z+Nv~Ipr+hV$*;1psbarQi0N%D#v?lcWQnB&se|X;lzL_(-YGgxx74I_NR= zul#m`oc9p$)Zh?l)`QI~?w^DK06$oG7nbDIC~~M^nP65M$FQwSJ!VtgzkGNwbdQBn zjF9!_#s!r24>XjgaAxIJu=wr0mAF{q#I#Jw@++0AFVSqLv-NaW-b3CiM>U<5;27C! zHpw=f+K49_J8T^zy3lWCZascr+`t+y-kol3?N^E0&wJFYFb^|;UE<%Ku`3T|)^mek zgH$&IYBaDT^6r>2U}EF>ap#^^q%87-@x#!jrh5?fB(@XyG%wK!BRHz}(}g_R#u?zH zKx~^IKkS+h6@VGN9I(IxP@VQLLqkS3l?GwH&-x8|4=ks&&7DmJ93q4<?oZO7UFV(at^ZQPzPMFvvxUubd!J6>iw6?_J4lmKT|VD z^-(S~UeKR*(1m3Rd=E&>nfpRB`k&CawTa57U$zSVtL3g z_wtG@w>qdUW#;N9)7MkJEtzEy9imcn_5;XI7U2nDUy_bnT@4{o5|WbmYtO44b`q&G zwl1&aKZhzF!9Xwdpk*8Y58f|7XKPwUyo$uR#`{X%LzUS#&tIQR^3||bI5agX#D44T zlLqsuv%N2%amaR9Sn5s}qJ6PRTUpf?+&xE?XjGZ+CqF+`>f{QNTa3`2&r$6N&y zKt~Os8TM@JiCcj?Uk55Gw&z9OzwcFTM*5M>MNS0(l5AKjb|u92fQ-ki!9OOn{O5-xHMOLh9FvE4gO+rI*Fh}6Vxm6ymABifNneSNu#G8 zH#(@ji;nus%8)c7O;n0Aq(>QGAbR}0X)jL5InVb@yN*;Q@5Q)lD*&dw$wH{C>=*Uhn+Ki zZGq#$KS(N?9q*fKXxumS!?OaPjVmaKdwB6c67A3N=YsqO+riX+81BzfvHhD_{bG)T zg`dF@uk&cmu)jXR$na2x#N(=$3AfymLE_-sX8_0_K%j^L*ma>xRr#ZXF0YmDI-Uo2 zKzV*_`HSg-3sq>KFC;B3c{+F;9DnYtscn;k(Ev%&G1ju#qc!wP^t!F@jzy(Byi*+m zPR^@7>aF)xRanfgiFEXniDqQHPjsoCcq=7v0|=*hly!pPZM#lcvZLa1$$?!?u{Jt? zT{{~YfB(ehcs!Y!xqLkRq-D=i{~!)a{Xpo*=+`|~fA+%7M|pk;L7ygj!^B5<`hCEA zB_AhS{n$KFI@++uvZSP{?$$v?Tde5PdXo_++ma_L) zc43{jd43hSV1`9?9*Mj0=F`7bz^r{rdkW+AIws{1iG0A?$lU3@$GPV5A;pu!0`(Wg zPm2(x7EF+Si-!9NpSU)s!B{C^w_2iUSx1{D$IjX5{$=#YX=u-z>f))XPn+mM2-c^e z6%?ACxA)WVXM{VlObAA!8%i@lSGMtVMNXcy(e4m6gUnkA(Ty~MT#zmlch}(n0>l%) zf9{Cc*7*;v>V8={fy6>ayP~vq5yFDoN9g(udeU&xud6{D-?;UswHiWZ2FIKgv$)h$ zcV9oOY-222SdmaI^w5I}s0Tk8MOD&mVW)n2SJiD&FLu4FPdb^UGi$cacMP1wKXktc zFm8l4CBg=7#=|4frRYNsj;cLeM4vcY&RpbQ6dNv(UL<4J=tkBy*U-nE8h_CMp!`#2 zj(O;b^IMF8ASd1>tLZLFh%Qdl!w!I261{Go(V2vT>G8mA6CQKL&E}!%>VV$*>Lwkv zFJrD}tMN#8mF1bqTb)V2h@HXeUK}e6%`p|lGGyUxhGG@2K z@MnLTuex~1Y$%|h_FWHX&99s*fCDjU;IBQSZF?7a<2tk`;pTQl*#ymBd+5=b8%_X8 zfzDwreo?E&xuf*G^BeT7Q7YdOeRP!WWn0Vm>aL1+zn_ATpxHM14wz(*TF*}ro0dic5M-8YFa@^PqqDq4VW&| z0vegV*}aYF2qwtsWul$GV1P>*3y_Eof-H*`ff|c73l72oo8xU5&76QlT;(?wHX9$i(^Gp?Z$Zi2Iqo0O3 z+kXAW|8b#cSmG=2i=ZQcAe1pk<_fc!Yt8P4R3Be}F+QR-4MoaOuxtFYV41fH3p^Mv zl*Ua5<-BcaWLRkuX^VWhx+_Aj0WNtct%k{3XSV6gV2&=wg->&2Bc9^*%X;MtCoq3Y z6NoUV1pldt1dQ6pUVsuh56f6oQ!lR=?pMdTm%X_k`JyhtNX}o6#^np8gBUsNw-ZuF zZa!MNPG{O`N`B?zbU^}C=u5UP2Db0U6&PsM& zN^VPHZ655?Nj%6(_-dB~L>sknLrHQ#p~vdT5wm4yO#}$;9H^B7KJZOYls2%^7&14n zB2d6phwTc@h7T!?^xk_G@npKkn7w~0gRNreTl&fZ${tfZtW}a&bd0hi<}uW zIcpAlE`DEtg_;AbUU4752BQKtY8L8_-n_!H43>3zkG&tvKhQt^;=wrff#EkDS#Vrv z7t)>b77|UwKF?J<-w;6G3}kX~tXl?mtTZ3%rcT$@dpLYR;=WPrXHkmzP9Zj7+DT)B zsRo%^KPb_WQ7yr$=29|N2M&qdoY+fMO)LEkda+6-g)K$k9+f0@J#w}EIRnp+cVhbd zP%eEwmzR~2mp=4G1@gfGWM%%{6$-9A?$e+fQ=~I?ULdCI#6SVx8E4&@hd*0ndL%Xv zd4sx8KjM`f;W3a;u19JfZOS2A50c5AS> zkQe174KLZe;ESr+$9^oVgw*v?Esphz>khfiqALg$iYiS%mx*6bD!dgwn(o&s`nLB{ zm*f+rKB863f5drBQd1Z?B{wAZ*bPhUs)^nMeJ=z#l!D=X(=lNa|2Omxno{qzN*NT7W<>jwaOGhnAaO{VF)rXJIVIv4n@tZ%cz8u(Kj5xTY zogai(*SpjopBAgxB6P-glF4+#RJt?a1ac(MnpyosWFA3j5PbYEZi4^K-0B|>_J5wH zQTqQrP5b}brx_nvUv0*w-)4mDjf5b}A$Y?OU+xw%@(JxEv6sgD*z_kJ>>harpx==)jkK4_H|73*TFfM)dQh7k0b*@}=y112#yCaF%@3O@e2O zHdie;b&vK3l@HvrWm1mn6D718`>0vmH+)fkuYo+Dw9@%#jpI9=`_)-?nmOM^{Ysn~ zSPFYm%Alk{o_!lH8LVFSIQ1+-foo!*z4DiReyiZPBm*58bacy=d z{CS<1^Nxd;%hb+P8;_(4rbr1+zxV{nQAjXXm`XO>6)w-t8)pbVB#&6{K8?;01y5z} z8&4~qlHKi>a3&KY@ebGu7>xg-b)IaPTD_`wcy(m0=DyZ;4Z)0C79CHnyp`LTDt{UT znYcK)rL;2dS@t#Y;yP7(SEWMdp^UwEkf6_)?KhJwBqhp+))ST+3krrD%o0qi-bqwF zm-@?)5ZW0d))Cv}TLU>^C})+pWgK$8St-U``qufFZA~Co=o`S+B_JY{7~Kw9h0BF| zFHis27+egG=}=9&etZ*=DOb|A{q$+=F>p+R_<;$DR`gE#=pIkAM1%gjgJyp@VrpcI z6w0%w{1MqE4?9`Q5yx!=$}Wgp?S|A~iZzTu z;Ui=5dp-8+_HK0f9z{)ogmtqmaZ@9Gx1 z24A6kNqDA|cwH4}vYtoDD9gNAOE5ngr(vjcFCD}ZJ2e^HvQ=`@T+2^z>?Dv9MBBUb&%A z7hiK_^v%hPa}Bklvh~NVNHiqq2=7RcfeQ*q==*~V0?CYhOGlI6y^;HMd+G`FH)wZ~ zgh{hw;2fo1a8bFy$-AMBHRPZd{&jtj?+^-~^|VCDC+Y}kU(75N4T^E|mwAX8(7E8? z6QUS**|WjvW3p#8P|OzBZEYROKV)u$WIDl|>jErtdsm#U%&fc48?k^9-v6;C>@aT8 zUUCX@$cIj!v{E21!i^>tGdT^CWp<&E_jVDjMp)=# z{9i*s4OJ+!fcw?K^dWD8*yorN#+8oyTU!-5mX>BO(H)xOTw?#L!sdt!n*a;UW zPT=u7GZYtRLPR>V>^(t}dAK7^v&~>$l(|UO0j*2PMm-F5VR>&<13TxION;0kE*3~Z zDPaXLo{h>Ue)mc!oHe-G1s;k6mUz{}FlGu#X5&YD_}ISo)I<3j^{d>VfzTlGT{+LT zLG`2Aqv!PMysw}k~qkO?RqKnoOC*SCQ?*3X_pHN6tt~OYa#Rx%ZLOLBe5}AQRuj(38@69eI za@OXs&e%(!R-jNj$F#_+Xnb*BddD|EqHFe_f__qQ@2<1M_FQ$=76Es0zC>c;actJB zGk1)f9z_*vMpD84?`aCzqmTNnPA&}sYLpNycZ;#Mru^7H8Kv*yJkk&rddPi6qBSUe zFkY?i`54H0x@i&EAnxhm!D4$5= z*@tnRbUfBl_s{i=E5V9=RxeO;htbIL`45iS!7VtO4y8i@gc~gD3lsK3uR7GS!w^8I z4i4uf8u^{4)j%GKc=^r~S7f_5w!^nG6B)mfV~5m7QeA%5pLBs8rCfdrIs?fM$?0aw z3wVB-rF>9-nIHiK;vvWd&e_cLIy?)Yg&=nH4o~#(Kc_hZ{ZLEQFE+k4$iDuIt>FMB z!d6amcw2`;BychoAlFzjrWtmz}n*CkUQ1VE`QNp zXEs!#5uP+!1Fnn_uaKZ7nn_FYHzMk^cOfvQg-W85YQ@=!E)-*4#Ic)cp*)@sKLWix zVG6|7-#}yngSx&``Q&*WWmp{gavs|4t6Ko=VLg(uMq=&-5e5+) zY-uRB4hVV8gKcE2%$eEwR*UEuXrlf6KL6VCEB7hT{v{VC>veU<4UUFFd#$|qhf4$4Q1v2;b-n{y{XIZT^8?t}A zOdcg<0SkST8?TPL#%vI$Npr0CLMHP&-kjEP%#4eunIi!Q7LYoe)=H&!p+dOq#m)H6 zILvOKkAAo|jE>i)QCZ9;p&oBiJxYp^R_X4FPn@ zL;WI6pE7xhUT9U&tU6yl4pD^5hsc!_8{TXi*ZOA zcd`U-iq6=at^7L@-+%}Eko^x%+@Z_}Tl(KoaRGk<<#nkNvlS1?p?>1lbSC$yGgrLI z!)ZWd4p_74Z;eG?sQsyQ=#D~Sc7eXWxI{Q}EO_tP{xQUEYxU21yY)5DUwZ@&{v9AL z8~5|mJ*8dUFD#FJ?E85oGly~De&C=wKG^s=VC>jFp@AYATIpyj%V}t+B~`l0g~H({ zygk?P*QlK|=X#4lb8pWM2AnMqYobqge>Mu1KL10_WP9-8yQjfZi^_^pvSW!c(J!WK z3yBk#7zN{f&V$VI+9iti zDn#C%@bRa`UpQJZ-kP3IJnJ9R+Grn_TR%G#+&Jjm-&cv;cy#tdr`&#}wBR>N8+&A9 zGDEX%_%g1JVMv-Xo7U#&<>wd}mtS=gy5H&XjUbDRouY0Z2@NlU=dr`+Zqfo{ym(Xj z=gTK$8ZocfFQ2gqdda~yY2{rXe7W}+A2u{pj19`JR;TKsE4~?QO!>kJ8X|7}kY}hd z?o;VQ?fmieYm_(jC;Ip(m(2v-(>j8DK|Xf8ztTQEqo+LKYL)V6?myG{T90F$iFJnkNG2Ge;~EL=t7ovto`d=I{12SQhWf~7lj1F-72wMM z`~xmnE(oM378d(@xNultRYMi?28@mHym0;m#hT8U7qAg9j`^gV83epB88?@uxV>)? zH}!c7=D-2{D49j8W}64tabc%-r(oSVsIk2te}g(n>TGso``kd-l@wF>^G(Xz%Z0#C#C zHA>v%UanjInzp-j=dafPkG(e!hx+aNheyhiqD_%y6r~7BWj9J$QkE1&OhpOV_ie_K zw8$C?8A6g!wy}+|lr=`SDTA>y#u&4kF*D!ar|)&$&voDTb^o5{`2F!5$MeVY566u0 znK?f5KF{;Lyq3=wU9Jl)<`Jl-jXjW)FI)+1B!U#E|ANrXN!n?+Hc zT|~qg*vV(h{xr{z=zi0qEAtg=*o}4(cH}2Qx<*zs#G{UWp@0+BT%>PV>rjUdf)3gU zg7m%Bg$?#q42BhmIr^^Eh4irYUHw>6e3Lh`yHSMg=Rat@UDi!o`GCM+|CW{~^6=B6 z57W=3mMNf*S*(DRXfZkqanTSRlC`_OB}X;1GBec|fu(Zb5w2&?f5fd)Cm4vv2qbC`tEKM!klS6iRR2whdjp{8vSL2qSXe|F3QIq*&A}L|ws;Yug)1L3| zE=WnF#dNQ{Q7mD?+Go$d8fsRi?o6jcU*I{$@t z(AW!3q~DpJJvUsF9i-S_Q>}%KiiAJ9k=xC3>|IcGjujy_xUrx~E9OWQAZ2L9`aPA^ zBl1w;{CBgdngVsniNtsc6JK8c4sZ7R zHr>GWhK%n6mp2L#OvYzdpseHYD+=4~=CjKXH;jSPPfp|MTEb#aijrIM6Y+3aw`d5) zSmJ*DW)$nuu&@5bvc>jQ5V6~UX8|109u;BXq;t@(uYOMpE9YBRMj}2Ky%ud&1!fK# z;LDZdp*1y_tTVtw%hC@i55~CokMiLAJj1gsfl;4NGl}Qy8K}3)Y(=8EyP465{;x_1 zi7MH-^N$l;mdo0@mvN0ctCwZ_Yg|4})p6maUis68>kP-+gwoj(=7xP^q`|`1+Q*YzvWXRyoN%?Xp%eHsb(q*`=@4lXdEMR>GanrTS7OYg z>o19{lnFMPh-9y5wt`)*;E(7`3bYd%C_Rc_rAa-8da^`JD_&FYC-ME*@7obLC;Aqh za9=k{Fxqo_EY2{@fReZEjQPq14871IykyyzwpF{rzh-Z3iPrzkVl+A6oWec4NryTv zZWcRSub;Et5i2EE9hn#Lb3nc#bOcJna)#cHE;e7;3LfE0RI+rfWjAl0buMx83s&02 z3((6**2;epq%5d*9w-%!oMAls=^WcK4B8CajJ?Jf42EdRS@7Z3K$JLwe*CG^ z&c1}!yO>F^UZnhp1v;ns^!;jR2yuL(h@d7jo9AGkHqesZhyFgA=hJPxtxO3yy{$l4r~#CcOId?s*hJ&c z=*m_U-xUr|iuy$-8TTXy_9$!tVw(OlQ@gwldF|DiG*_lb(GA%|0>PqLwMM0S@f8Vg z)pQQI%r`Te!;8wPo5yM1ZIkEn{JX2}n(8yQg`zEefms)_%2h8oJcBxNP2*>TM^$v) zZ#9&1TgS4gb`szxnpCeV#SSn`F>IpCF6^|j4`J{iauK-xpl6w0*Dk99ZG^MYEfvk^ z$*`p=Pc;ptCxNwJ$iLEt03G8oIonVN@^2?Tcz~sEok$VYut^t^ZtzLc*%8(*tRycE z7yQF>01`>K31Ez;9F3lF8Z*LY7?O(i7JIhD6y>?<+S0^zwPy@U*ud5?nAkt#D5&0b^_fX4Y zb{tL6%n9^!w-#vS}TWO1g9kj8fZgQ1mw zTTD3PoRB;vroRUSpl_{e$pn^yo@LdJ)p|cR4cC)4Bb#||)E^6!aVqNE)$(h*NDqV#t@*W_; z00HQw^`=Iz=M^97-$uo?_c%P+0ggJZc;t?3o{n+ydD2a>>KKERdC9>Pzq2d-rfz%B zSj$*8yxr4d@Lgr|=tbiwkV#y?@B7Avp|j`HHio^$$AYb^#h%7m`l{w>+K8&P3zxg< zF01cx14W{cF|fVXp0pFyruRP=F2?(VRCOoC9S!!CBcH0s_-&sNbitZz-u8IRB=B;7 zls6o-x7#bL`tjKLEFlH$Z5Ql!6$mTHOHw-9A>yPKNN8lKj7iYVyzP(rg-!$%4uP&D z>^j|Wq+4+3EkSCH-KAvyqT<#|)g2tc?;x9h+d3s9Qana#8->)RilD z2K3?1hZx@c$5#NP9WN?*XYsxNs9g_z_ECyowf| z8vJq2v3Bv$jv7a{a(zA`5x)+Tr7-GVWoHELtRcfF&Qb=+_M$)vN0J*Ql}mlRrPNI|U5L@~>d4gPdX zFYTIh>x6zV9T|;^s*JKu%pC>cP7G z7szC+vJ^;?Fw4BqkH-LwLN;LH&X#Gcg{C5~-Fp|HGQu%gPPOO{7m&8^?Nz8fS zc-j{zK}`>aHkQiD2!WR3MI{r&ZCdvyN^&nKVm{OoBT{!Xo{r8jU-W5uv&;L`v0c$u z)xu`{waToXstsq4t&WzQG%YzXj;vvR@%Ong`Fb>Z-Nina&DD_H7}tLmXtl4y{r64S ziQdz{uKIX&4s{Ljf49x}j}k`P=gSudy$*cQc{cXM!}aFLdk=2*#g&r^_52sgQ#t>j z(iVFhE-DaTiH-g{hGdh#$T~46#E23HU{L1Vl&__d&c?y}! zYg#IcP~#Xf9O-O^{ZLd@&Gff~*GaB`A0^emyOxd0@VTH@w>Ykn+BM$A6|H5>p2)hXiB)pYK~Ii1nnB&7AK$k_P!W%(wllWX z-Zbi~A=Gf0SbI_JFGFq1vBaNfYYdS75w8bYMnb)_DtW#HujUA{{|6*;8vWyHiV35= z8)&E-Wg7HH0vb8iP=^IHsD`?QWs?^h-pcxC(CL_uq!nG}dty~3(K#K3aMm7a02b6W zxIH#H0U#{sl1BjZjZwQLdw^R{1-5{2XqC5^rpeu9IjweKq(4uO9~9UPW76u#ZY-D9 z{QCfkumPTR9pPVrg!tGD0O!N}Xop(Q zS)b0gZ!KrEHW$>8ScJ_lN^VSYEP}@9w)VV3phq{U=a<8>@dJ~m&ea3{em|lkY=fBB zSihlz1H`&I8hyt>CKSuSV!sHv#PAcjC(G5dwpyKGq^Npvhu?fCwqTp64mhQXKxFeJ z^Ei+UM>w=}VD;?yaw2SquQ6^QH}g*W&d-TU`4W34oUZm})K8GDM>~TU4KIGV8d3<* zFJZ@?F{A8~3-Ha)d;JeOuiAFqEd@8_mhjq z`4JZS;`CTBVc=!5KnScJq7`L1ujce!uQrr5NY+U;*WRDsxM~%V4=;gVN6eIw_`;ir zIj}_g<1{nHOUa)Fu6Mpd$$04K`#C5?4a5i>5Fk1L4rTexUc3A;A}3-}A`1?M6zGa4g84~6_7a)4~VivBsjQuu6%0RPCS+Ve}z_Pr`=B$6!+4|vc z+J_$AP`YQqHk}q-3~cY|1?WXlytzZ8!ABRpt;UtMeF_NdCDAeSBRQaj`y10$CyuGP z=?wZ+;5PVS1tL%5%Y05U!_PGK{OT^1Lt_ZW(zJ;?PW8X<2X}u;1JFuA&8Q3YxURv# z;ga=Ha>@2pfdOF^aczATL)_l3S#FV0f54b=739CRtWQ^+$uOvco z*Byn`G_(Ee4craf_CN$vx(jxYrNdTPPCu`Aua&AiiTczDqDBNuubFD)*$B2C;@%rI z5S7j9I{s^|ao4LjVX0Md38Z2B04TBrTz6o2a4l;ye5Y(`_tG8L_y#5+Uo&Z53N%Q; zcaxmH9llhb-o4d$@8+Z>IMYGE_`%{0R{^B9tbCi`Vy+iKxg}?Q_g1$W^w1di+8YgNctt!XTHsvJIH0Vu8p6cy`(Swk`K$S&3LOIyhwR0rt)1Rs5;r~- z;nGiqV`m4}B*`_fT*d^)yBz7X9u#5rO~-Pt-p3x$f?ym(E1o`I6=8MoH)jgU@%jK9 zBJO%>t;m&nA5Ik0rRW^sMr?Xn)mKU-T**(1w=V1w*d}KfnDxGj?Nq{@CR3;TW_KPO z<8ioKB|cRyO>a`US8{Z&-qTflWI&-C%x`Q%sMOW*yeHNUV=yl`;7IlQuOdg7j z0@MkV-+ejF6s)%r7nb7$56G6mp%qo`SZHQ0xZ1*#3_tf1Ze3-?`R)5GeFE`T($7MA zJ|A^DOqVriR&XGolDt8N;h;|5$a|4HH1e|}=;YRM0&R3Kd(NN+GAQ{}L|W&?Jib^TynR-9SSuZ3bSmJQpW5!W~*9AD6DRNtK<1bS>qx_=Cm&ouz1-8m{IW-9W()b5vcMh+%;(i zpn6G2>CWpKH5#~P7$5NAADwo-M0rE4d}DCeRX$HVzJUi|L3)ENi$VjaX>s3xQ_2Na zE07DM9MF+Um+@6yJN;PU;Im z0>ZisP5tce*s+^|lMXVbdS@Y13K@-n-z;h3@skz|oab`i-M(=(S63-?VOd8&On_MA@C>AI1XKq&OPfwkW`4L5S zY&(mP9~X0Iu+D%aoZpCH%xt-%u?iw1j+wr%_a`^X>AROL6G?-C#!@wz?#E1p0ct9c zUl0|5Mq2Q5dUHbh9?O%YNm(}-bUef1=h~&YVsr(Ps}1!Z_R3dnLO=SkVmf%*%38XO zh@)^D3YD6HG*ZG)FU0$8N6sty$fqV0li)joEi81Ow_G{PtT+B{P;ZMBR?I-^=*Ty& z7(MYYLcHYGN2>h=y67y`2)#x}p`alZ6%4vTvu&eSd1+=%mJ)5<GGm(qene$RaP|7G@6_0O8_`xEl9dw}^~#-9gvyh}Ea&E9P=M zNOwN3p+G{lKpfWrA$ChIZ~&&?2)h1}Al8hN!3UgV4}%ucFNl}<9$8RYSssyzg{1ri zx7%`>-uI85_$)hf zH+5%TePEZlf@_!pxImO5I9nV7r<{)zw$=po8?&!FfSwBPdNV1Nw%_|v-+j|QZ0URz ze0(ZoP1tNkKP9HAW&pV7Ofbg(qz(D7&kpOXW`vK_X|b$}r9~%p-B2DFp`$A*s!AAW zFP5h@dnpf{y^y9S>0m?Dn;+Yc5p9yX;aiFUquvX#lOFl zNlpLi8WLv9cg5)F{bTU=r^IK(e(%VeadR)NvWm9;3-s{?h=~wg{psQG#ahAsa*(pZ z-5AqAgiEw;52HZBhXVcPta$w=u*eJdz1g^u9Eb#uKvDo9gQaGLR7>TDr~J<7XOJgT zx<3|(A0OcxLBO3=Y!>kah=k#!4ZG>y^b>~ z*nsc+gGzo^)h_kwfsc?@OU8&C70^$9H`e0t$&)Io{xC#=lBF!DD?i0?m5+;2s-+~? zJ$#nxd?nUsMf{iW=q|ugP5!mO7sP%8n-?~ohjv)$(DKBEw6MuK^N8z@wp|7g&k5~^ zM&k6ebAE$O^7W~ZUGk5%RTfbWeTyh*cAALWi@GEd2j6v{_saa&Z%hhEPm%Le05*-NBajd{)2v& zyWHGmlh0EFlNrmS-}*$mrtrO91S%W#I2yM9Sm&H$&g<*ms!mWN62A^9O*?$x`B_vl_P?;TBf8<#Xc-=*XiY7M}JD96vu>%4?~U*Q8HT$$rr zH@mPoSIBO~=_{F8hu+$8=z&FVt|Ij@$enw*<7nyrel}H+Qy*pRZwqZtK9dZ?rUEU}e zWVSZ3U`;MlAz!U`WmS}`=kS|ghco4Ms0ZZ32g3o}ND!mqNJu5O;(<3xADy*)&c&QG z^3!`7i{f|?evhh1ejWA4)Wdnf?uyVwniF9JD3A_|F+*&7FxIplMQp`AayScV-RuQ( z5~+SX7uM_2=K71axs0UKlS8GwA4G2f+$-;@-;Z|aN7_*LbjOY$+1+2k&}+wRKTh{! z$jFkGcR;DwPJXz(-E3-5WF9!Oz_iz&jO;n z`AJBn^`=sHLX_4#{FmV6jjE(5NKsgMrz1AWNb&Y6NBG%UoFUX~v;qnZ7;+)7HZWfP z^2QJLUBb!T9P#?}gt!Z z!1so0ftUOCwYZJ2bD8zPT2gs5(LT!c-a5WY34>qv)uY0n7@ZnDmT{puK=6%$%v^tW z4()PUeM9^eib2j(#JO)X5_?ucPAf^$3>;<#4o_ocI_ffarsM?lX$H#_YNOR94(SiR z42i1!YE`0r65e7{D!HGDFJh52epCay^adv7cNFdMVdkv}mm>>OdVT|bl;)KBIfMA! zhr^=!_Ab3FPwAO`@_nSp0{Qykn@`oBfVm0z8Gcu*0{1AzrDz8Jymb#YYVRCM?OF5B zs0;VhGJBu(D*3xi=*RnhBxGMZ@XGZQVZ_-3Nzr)sAlN`jmn*Ej@X3FlHfWC683Df`2eg?H~ie}=iW|4p**%?vJ zMufN)fPyV-4}!zc3-W`80K1M`x0Tt%uvjo#foNdR030jX+7MpOpgE(?U05p`b_s2A zu4VA6euCYtFRZyQ(6F^99PL4I>&bHPvW_06uUN$VzFWGATz2laYg}v!Vn;;ZDMwG zoJ{=*>|ExsLBIufjm{1)U97~3E|}%V@WIP$+n%ascqp4gU*RNHr`(3Lsxj8Mtt?+n z7zQjWKu+0M`1|?-kvhb3Qt1*Zg0s>LcE3soFQZ)$iAYC71Y$-uZ5=xW|4tvNCr4_2 z5NU76ap13%x3>E`JHD2=RAY%Lp3%xTE83Jeh_E?zgtOrUm%} zj|cR-FYovh?l1IHqcZz2&M~ z;)R1EU_9+eGWE>I_Ziiw+GYmNh^)?@R@{s(;TmsD4Zi$j;EzLnuNY*t-AJFldo$Tw zPVRMp_$`MO*#7&5iL#3}KK=X6G&|8;g>UvR*n>&eX^;4dyaKivv-Br4#eZ*#;nfVy z8SZq>6_V4-GfDNe**;g#KX3_%aNLV1WJgjWXPLP(XlawuUrm;v#+|osUztJLTu=Zo z-Pjy{8)SJ8XL#D+GnQ@`#yu;O*O6WU>r)#J%2W4&w(WH>vTOgbw8oYpy=M{jlebv!M@rl zH#FV!*}AQ?%&mP=F+biu&YOKVmYEjQt{_;vfp5oe7jsHzI9Nz6ICQH=TAJN>N_061 zS)i-(NU(?Nw9(y>qyAMV33uv@#n)RZfI8FmnFR_>FyBPpd>$l!jbb)?Jl4IyqWrgo zj82Ne@yUHRzIH3*MS!eCmUBQfjrhX{GlTt)IS1AcOt5Q~?$` zz7@O)w)^TDmi6#r^&Yt*2W1a8K_yv|FxguW#C{@qN?;MOzg2G2v){ZmeN3(9EAw&t zUH_i#2L*Qtfpnym>H&*j>T(BW*Dv&QpQ9-AAM*DmlqLjZo^}5a5u~>DyT=+Ih9BZ; z7v9>8>r;iCxU6c{?9&nQUW0$;rGuB1dD4syCE7e7AcVNqj-Q^?v_u?adD{ z(2`9dQm(Cas^t8u0(MfT|KOZ}0c1y%vgXUs^un~ZV>Sy52@J?->B&%#j-SK_<(E-vMZZ=u{6XnQdy4$78{4LaJ)9I#5J=i~`a&BBtbNOD zS7`c28{1=d&34@mhzOA}?qPo&F^DsR2~A=wS&q!sY5P_t?uWHIFRJNqMzPDKZwktN z9SN!cc{!kaDL`mv?7Kn{h4{Mi3^`w~QcHXVx4Ar=IniDI)M7Yvt(Hima<$oEPd|cC zZvgwT_%@!9v3&f9WYXUvi>nA(R3x55*IIoBWTS#*8{b>(rV?JCypy!x#xUweE_Tle zkSE-q(zB9HUz@@%E8N`^zAV$~nsQ&OOEVRSs1~~q6WyQR9Qj=n2G$Ui`TFawJ6cDY zF|aKX-j1B+gmA@kuWTf|8?{aUb?Sz!KwAD=VPC0*2hF)X>{6<5UsY6*z(t_L6 z=FZ`>G?U8HP$}u1;da=v9%0<>pYf-2BH>W1%hVNS1sSf^M4p&Nrv+a88B}6)b^7+* zB$zj<3-a8*HgtPzmyYs*cjHs=-RPNSlo9=8Z$)O+W^g^3vc}Q@g9-KL1JXS0WCE_F zCDB(jH~sVi2R%xI5DdUy;@5DluYG>`1Ka?F#>JfY;Pe032KxKF-qA1h1Z&$Bp$Xip zOi<+0w==x+UbphiI`!f%fMjRmx&-_4 zezMof_#N*_l>a0RTSX;Lq!1g=G&wj!Fr|yMEq>|GD6K-n*BVC>)eofiNyWFH+L>>> z{VSl}65`Np7-9`v*-U z8B%j)F4#!GPG@+1`r&9XZY;v7%7u6>3+c>lxv5s1p>GPu5U#F(nI};ai^jsgJ{YFV zX>wOU=k3Ic=s?G3Xovg7M4=c%!M6JI+1bysk6p~jXck`4CG=uY5&#AKS6@*z&Z`zc zic*PX<6+$9t{0h{~?#Koa+GT^b$LCl~xs3CXNAsrYkWRi{ges_Y=e27n zMdM+-$(d_4KlXl=2Ia*>GTWr3=Rx`31gol?_y_6EMx@@?_%kliK5JFJXls3jfwtod z9bY$d{)$?+Y~ToJr~4&mY$*eog;%WEjEE)AZI9jR>|JSH*4ZjLrAx}M%e=;iE=5S4 zBl{q6i_1RAM3;Pf!}SLzzuXnSvT+)HqPt)|HU=cswklGY1Kr7nnfD*4umjBMaEyu0 zKV6>+ao0DLyS!n>3fus^JQ@*52mJxq|1p@aGQ`Mk0n@ck01PtA2b2RE0=t0woF8c; zED@fjt3VZ1NU4NFpuiao3evNF70i>qcx&3gQ{M+N!~+@_e}TS14|r>D*PA zyFEf4-V4C<`<+KWs~e4Y`w`SbeYP*_4hDz-9B4gJ{*kc!*_&XSY_T|_&{ciLeCe$5 zzKJMviu0RqhhG!p)1FK1i4M$Lrp(1CWz0ceD(QNg1_Y*Ft(K3ZG{vZ5C=%aTr*p&W zMDveo*7ht2tOzKBu_%y#4d&RlSEmvKHG7q(V~+d_3ZV4DMCK_Fuw`{)qY4f{uMtZH z;=#~^jk{cmk8~MtI5_zApb^FBHy^G+Egf-djo=ONWA3dUevVKzmhAI(O80*A$Wv<(F?Cl{~(I1sEXh^7{ZkxQnEFFt%#zc-Jh!(8Uh#hf+JFYK>` z9xL{=)cWMAvylHJFAyu6ZUvGZ5^lZ26+k&@p7jjP6jo`kHeVA&%I5)6=E-5XZ4=pv zN+%7j&wrdO_6v;93nJ$W$cfIy=>9sp+i}sL=ka1tSHOX^lr}K+GRP`lYJ9=F{N8!^ z6l9hU*8|0|E{xVPrhyD&>~!a|B@GX-NG!)G4(~D+Y4GrPguLu?iJxKu`{U z#ELc;Ou33`_yRw32*jak;;X0-Y(;5h3X}kEJ3h{Dg%)^nT3Bnlzr9tMPdDcUc7qp| z5|ySapbLrazjsp$Ta@ z(X`%5-+ivhYjpcFbD6}me_kjD{MSMLAkJW*)0a4McD=b0{S=sz*wn1?mmF?Cc`Ono zY4}_=g+{o}cC7J&p1pBQrxtrYryNR#BHoVQ3hP?!(>Qtju4eM`FM*_xWqOaSgVM%O z25N-Z#LWewaJq6CoiO-SDlFgw6M1vdCxz;gBNKwy7kolSOiOMc=8UynoAh#e#5RHX zWJbbOIZ0`&J7t%3EvEHMRY)t0pYr*Ko(D?bEH%x@TfLd*G5n&&C-_*uNJ-WAlb1c3 zjb8DGvwg(eV?^7xbEvvNH}@})zAD7qvEsH1!_%s0oHOX-+>FGMf!;5uB(8tsmPnM8 z!EqTeF^~1xj~z*mn>==udW&41f2!7uO#*LxW=o=>M3b8?4&J@dt9YecuETqIELCU1 zNszyTn*#9kZP(_}_+{4Z*OW6JB^oHcgXQ%vxp@m58it{C^O!I3>%d8iW`Z|t7>l)U zwI6zg_n+BXPD{DRhgWExQ(i7#MGz;ZP(n}d;enQiZb2?Y0|kE@qK|!bUZ{s9oH7Dl zmQ#__>7A+~LWz_~V`OsDt-YA9Md@m{xm^BM%m;SAP|x;gIEKDlqVcf8msmkx+kz=B zMk6Nw0^LZ+w#m9VacjO6sOHAo-k&Wx6bZ#b0r1EXYA+pb2+=Y~j~I{pdrbb^H~?9P z7=XXAF#?!)4(tR0zP&>vfp?{nJD>cJZxg9b7UXdF1z+*{7HZXcWA@=7J zzp*(8gGK;Ep8{Rz-_L?i{o}=pz7OCze?NZ=M}aOK5X)po6S*C+4RxsOa^SJ>KQFqF z?fV_Lit0Zu_8%XiR~`T2FOb&OHlR?{Nrl`4EREb^{{l&3z8L{MD{utAaU-5@o(JYV zT>^7bG3*ImQBL#;o!F3B9|<)7@O{{ag@}|N4us{6w7I`PGJk>G(edzF_XKGBUm&Td z-9=%YmbR)MSO?p>`sRYED}|0*olU%q_VN{^yV#Op!ldSuDcT+NVri^6miw5TNQE8k zXgTg1Ta;8>Nf@-~LW!QE%u@FFSC~dRcX(V=zg>2r0X^JuniBa9F08deIh1H%_hw^V z?@n5mO`#69kLI|?`?I?L>-(+_8b3BxsSC*(64%4R`*HU?ZK|YhrqJM9j_`JwR@Ep~ z{s<*yjS?A6$!JRztz6uAO~HMqm|d)UWm#?F?wQ4u*L`u$Byq@l?cDF;bp1%U*yOXt z7T;wb)eZQ$KHdkd#8Nw*wk}$j#fi_uPweZH6pE~6Gm(XdgK?gk9lgrh{DClmEweD; zWPV9!{^@&P@?|JD9jp=z`0uWa%-k7oS$JP!xBpq@9jKU<&y|gV^83_Jeou(@m`9DB zyTM&D{rV`qb)xmCn$Kh3H6QmHRCP<7lT)*PvxlQhu2Z?k*l}bd@~!;InYa6Hm6#@9 zw!bbsANYhY7_@rv#;V=xLLDo0ttWGn4@A2xuAKR&cC^P?c=6_AQ?PVm;meNuC+>fF z^9f+XKej(yr?MGkDqpL3{Z)iynbi%VZH1L>*_8_2E0RY}4DR#yXc{}tttM?&(RK=^ zJEJ9mkPWaz&HKxAS(TT`n$NrKO(*V?=S;m_a8C36Vp!yLUYI8@%;y$ z!@v2O<$@?bpNw6{FKwTR*fq9f`=K<6jy(`z+nn+`ZXTxE}vnjLd`pIY>y#LbTczV#)BMoh|cC7 z!den4r|Z2{U*<#`u;3dk`U-O$jP5p_&zq+#BII!p)QDNu{Zo=y)8gT1qxDD=;AHQVf z_4i^RBl)Mz47Prfy-MdJBO;M#JV)4oOeH^P#lg-~1(ug}l=I)rBYds-brQL~P%?eB z%J(=@+#~ugkotJ@6&8){)lh_?E6P$O(08Ks{B-6~yZfu*5I|15e}GQl@FRIW5{MOJ z;z$(|-d}PklLJvDdZ9va53u(FtpQCbA2M{rrSNT=QhbU(hZBfLShZdEah$+;xresm zd~uer_&lJCm&;8@&)yd_i2XV#k4S_~G7NUXY8$yI_-qx2xL{V7Spj!gX5oRU#*wO1 z#WQ*CA0VYblgwZ^FdE1(pc@y)va)=wnteym^G$1}Ke#^1qk(+5qm?%kKwB+X<{`2f z%bPnZj_W83;<=CPJh${&{LecBuob+Ba5aOjr{0qp>3)HXVq6IN+Fmsznn9afGE!XC zLnU@%q?|p3E<}11PP;h09wafJshge-?0H6oq*hhTs7!YzH3Wnr&wC^m0rn?qvm_d` zo{bnpTRyIPh&HV{>{n^P?~m1r>KjMpUppM$k9|2~(aIOheeBD_1*$b+D>^G?4Tik0-$4O~vCmtN zgyjZl+fMa;9AJ%MOfp9DHL0&?Y2=q#&W=I}MOpJ`U5OJ{O39*FN(d8t}SVH+3fFKmfaR+STh}?SK;2l*QUt+1$vw=*0@Vq;Klhc zP9B|@Um)IMBY2kl%t(8v(sih1t5%agbDRY}`8ZS2Z$0401*K(!hcWsC4s*)MbbYDy zl62--TxNhdc?~YuPF(RfmMe|tpWh_k7*T#P?F48w+o=q8gFcGJH|*$JR}ubUDzWPV zsH2rq_05aedn5$qcK<*j?ev*2=LW^HsgbhY1**WnX1#>8oZ({2)7c{@wAbxz>!|&` zwyA-c%XwQ#F=5jPX2RQexzEzi+uN1yB@17TGZ!wq57HUb_X}Mb0fH?)l8%dJUW^RI z(RjOclLmZ^0xU&kLKTIjrMFN<_p*#n-;M?Cz0-i11FS^zGsU(}hAcV*Bm#};73ywF z$m49o04*G%)VI_Vkk!NaAjWyz!NUmIDpa53C7|3Fs440-uoHTsc{i*LgRT!J8Ow1a zk{ebpiuxl92KC%z1y-zK`ZQx%`!fG1WaKh_Nsw#2Tp7s%vi8G*9EP5u>Nm%C4R^vD zq`Jgh1?3x;VxX6K}1$~_IeAnB1_WEQcXYn z_ASK2Y`5vMV8VmydOZB1W|ddyxu&krU+H8uW+T;UAkOuRmA3!rZt^p!Nb+rsHvr7E)w>zdkEnsBKiy2+( z0B41UY1QL_s_QrtTTHLs^;Qdfecm`SGSVQy$1-C?EstN!)0?jldhWD77L1;zuVhz# zUVe1qU&Ds~I}HTUimCZbXRQW#O+LJnXe?K(w^Xfo0i0VakmM(>=btu$t%9WPsH0f5 z6bJwztE*hNkmO!S z|0W_k)_B+lX}ZJ0y6*tg58k8wxT<~*P;c9fKK6~e^rhf=<@uP?#!9YI^pybNs>-l- z9_+(4Qzmt6h0Bk`l_1vxr;j#{$?j;TX=3ogvG|czGqe#svq@6&0KI;FI-5{FGB~vk z4B(zU72#>6z$r{_S47286XCpTu&gKH=`{31oXW9sR&njG)jMQn{gJ@`T)U70LlN` zPvy5BQ&?*Ee|rV+EDubh|cvWbn>u9z-0yJRYOh8}f#4l^fO#xzODj zMjDPW$oQTXueHA$$V^Hhw%lZ636Uf#%~7{#!0u@%|1`V=6mhJ3S}sB!6_^iq1hnsV zfE=qIIK6qxU-7dZIT~Xaw`KnmZ)|?RYuc78;7QnxdZ~wI zzqESj4z6_v4q};gvf1;87h>)%3tq@GY(B|o%lJZ>Q(whKCG_mm4_qnq7D%#*|IX}2O z3NAsZ$U5rv0GW%NbR$GSfXWMM*gEf`A$O%f;x%POiyZ;%4H?ke*|WcW{{n$rzI(kV zVV$(mOURSXVZ$EJJ6%gnVsWL9uZO*5avcF~`Oi<5{YzDBF1mv77pTL*pEnO^R$S+N z+k%~f^@hV|RJq>)>H1I0V`we2ab+Ck-dc7xe;fLT_2!jzNDG|&$>^&Q{-uOl3wi^n z6@Ze`xto>3WBixb+RpVSC}iD>&qojaWIShbZ$%zkWC2KuYiaZSDK%{8z&t-AM9S59 zbOq#dAI@u)#PeP`b~zkaVDuj$ln3qiL*=b2Ar%^*t&wxxf4@J_MCowwi_nC;0^dUT zeow4+fo|>uYFkS)irn|{suIU^JV7t>RjVj*+4-_OgtAJjiF&;9K({0smF)bgJQ8J? zfA}+*T8H_v!BV-61aBBQ^p)~Tx@5JosZ=iX&fu3@K2St0@&|AGmMtApy6M2Cg--F- z0H0<{_5Sq>%%T;z^~QU0^VYB1Ajv zPsWqXh5%84%lJ^%m+H0F1+b@4A`l@v)wj^7gzN__g@3$egm$}fFpB=7U% zb&V*GS?1-5EK?KF5or3ddRek>c=D`i36KUZK#r~btrI%|tq`I;1b!9nfNB;s@ zt?i^!Oe4SjjCPOlYGi<6yr5{=Vr`? zyT6k`{%8Fr&*gn4Kaq1VfC~D{mb+Gqmi0D`of&jY{l1{nOS@7!e&!SX#CT=d=nucQ z33{F{`EF=0iA`k<((WzQ`ocM}DJ8D?ro-bw0!6hw%VEEx2eUw5)WEgi;qr}*_@gJZ zrl!uZ68GJ{SC6Dfj{V3t=X=bd#Tyb6rmkKhwDhaK^nQ3tXVA8EM`+YTd20C+4~>X` zz|P~NuFqb-OJUO5z#kIhrwz z_3wKtr_=*a*p)ffTVH$2kr0q6w7(6799SMcE}R^wl!tXViYEuN}^%)hWUx9U}n$uzk|1d*{^zVxS*Z|ExP zZy#}Qp`2*Q2cNpJrjO(<9YHKM#_((MJAn$HtmLUW;xd*(JMXO~m+cH?@Vvc!78;TF z@*#VZT+8M^@*Z6?DqmRFX<#@Y-C?*YG&kl>+bzMAO1-@KN`FkFe1qI0Z*LZBf{*so zmcd=yBKk~4!)NweoZ8vkpi3U*3$~hpZLNq63-jKRs30C(EaFxwxeZtC8^JVju5SNpLRz3n-_TIn#69%A5MRA~ohwm5<#3x0h z#e@ObkkMvI5QoTjxl}6C+SJ+{GJ>waX2_joQc?)rG-1Iq4(wr~cQ6}`MteK1oyS_E zuSJj^e4-Kpnx@*UP8&7wZ!6I;rL$9k>RD_8i!Wx$>q$7lvR?MZuJdd?++%1BFlS5| zH)VcwuDoyJ@uR$m$_XAB(9k6ey*Ufmf9h-Bt|hn zM_Mr{T9b_Vpzkj9$|w?6zg5NJ5vTzKDjAs05(%w|XBMO837Exp@I0OIS*{V`1xR(_ z=(^60f7BH^zaD$AOCNrLAHjcp1sj6nklgmwO;xNmrSPi>)x0~+WVLN18s9k=oUWu<-*q>B`tp+?{Hrya1sai^2AiyW`y3r`C zdH00ObJB{wKL^Y52a=-1A;|71|5>kB@?G6_r}sEZj`e-H_2Lt^JvK;T;fsTx#X=#j zK@d0By6NQZQ+)i5Tjo2brI)^jqPu=rpK6rJUflWX*ak>z?JWLjc6HrUr^m~;(hb2q zckX(t{OZMA-u-l8K_Es#eKSKjzT|8Abcd6HDQyR!&RCnIEd6+o&;@CkWUbnVF-Fpq zsDrlQ<)9SZKx%sAy9WJ~$F2oBvb)ZK!lVB$aZ&-gbz52OX5Edgu%zy#G9y<|18X@M zjm^6o1IvBXs*AN4s}q=kI!<|FF(0^OexiNA2JfY7Hk0D$mDhHtSmj zbj6(DV{)pV%+)r<+l86b#y_Nf0x2tl^)g}_71GZLO+OM`6$^*St4)s7{5lQLUJYdL zR?e0FFi7%mUR0p<2*sR%ohI@HvOAC=Offh0-Jg!Wc_*f*^5sDn^hodRGfpzk+uD>s zFt4r+xW^r~>w?zlz9f>pzL{W>>_SRQSi9_DavSa~-iH@#&x`c>0*LKRMRHrn_t(gK zF4P4)_}Q_izKbUyOy3 zxABDYtKYfruO!`#k59H>aN7VMTnP=z?y+~{6-^4wVkuVNHvIJS(a$lu3MDo5#G6zq zzxf@Y_EXX?dm(FIKx6N6p`4D5fT4vjHUq%#j-#0U2$Duj#1%FY4Yr9_qJmAO1{(NehKSmQj?nSfcDkDpE0}v`|!%B>O&= zAw^0vAt^DV#3V75ofu15%g9*DuEAjJ3}d#Q)AxJb*L`2leLeT{Jg?tBzu!MCpKZ?b zvz+JoKHkUsI1cN&-87~QA&+i~6gAdDGSHAk<$S#%n?u;hR=_-sKxC*rvcX=#N7)p z3YVRE=zOW5U}dq8vOau&<=dgqb2|=9$7Qd4^2RYz>F^0JOBLVlsN+*b-Pl@J_rNwm zxxBmZzhPGX8@eUTr)qmyoXH_Z>Rj(>M9DbiXSVyM_-p<+7Dmfp4NzfF9qL;!%_dDY@i%mssfb{hl3S1ZCI_;>hP|()= z5&oyMikcVWzA_PZ0# zY-QOtdpOjpY&x#0LXYz?Z-Z1~jGiXa+NdNVivHGBNzH42Ul zq(E-2nTJ(y#^0fvO$2*)0hW;o1%`{36hu#LV|cXoG8AA8GW;Xef%I@~ilCqv&Bjt* z8w_kkXI*X<|Fv}0N##&5eX-8e*h_yD545LFg}CLG^-%2tFJM)j98Z33d;KFtO+s1q zSui~+3ZJDn-tJqkf4;xxlbw6TAplY(@DHshs^;o=h5<8-@|Ri0L1Spog_N7kEWxuG z_lmO<0Yq_y^MXx%8>#~3rybs49@mmXmt>kqXbV2pF}p9y^`!lD(GLP^yReh8OuhVY ze4It!j;xAI>8oHM=7WWZ_8BxBiypl_V36^4a5O?k?zi-V=aA=3B`3Vr z2d}jXGTx1PELEBG4elqulo8&&@?Zo*f&~e!I5AP>$a-2_E5i6c7lHpt_4)@Mp&_&{ zimWhi;6zg>0W>C7`dTAA7DPF@rlP|{?6=df|A#-URA7=Yy>yc1jQiBusc3L&&Kl1(+Tj_l7<<%rY7bQ+w0jG#L1O_V(D_i8B{uh&d5^h_muSNy7}obxkpm6CY)(fxQB~r2g`mh8fR#j-dSK zhFq7Sj9=N#`qlNq*2hug$^#ZVVzw3+E%CrDZK-m*h23EC>vmhIX_X?>*T75=fkA%U z{{GjGuw9IWqL-|~2v@d+3T=xSLJ1>gxx6SJUym5KP@#|~2D+~9tDb%ECQ`+Z`pQ`- z&IQuFya?B8bO_Q5#W!;RJDkQXgu7X=?TQ4dv%Gf=HXB{6N}Q30&i60LUY3mfwU#wr zXBFBd^SyP*WLI`R?#RpC6}f7IZZ+b6pfw3SZA{-K&kTa_O$QWPH7a5BK6*=#h`*ED z!kOAR^GgqMk(M@@Is+$rIV`^f5j2Cf48G^C+1Njj?NNkvFW20FCAhJiz8?8Y2v+Y= zLM{G{2@j=9LBwCd@K;Ndh~ib!yYLcokX%ao{~@tW$L^6?cI^NCzYd7S}(0ZQaX+ zAY?L{OrJ6pFJ-bQkg9ZAHRYkz%%T}P7?^*7(Z9lMdB zdwOhaFef8t&HMLna`vu~kyxwL=Bh&jw$83OS%k+ri>|pp&OChua_&!6Mb*r_Jr2aZ zo{=@TB*`9oL$lK9pp~v{UnMZ?5UdUL6|~P`Ym{AQEq*&w)BJb19x@cj2jN%^@sy%a zjXRshq@~4P$=S|k7e?<&`OV>)cBu~#8`2{wRr|xZi(V zaWB)&pv2+m%cK)e_kL#zqQa1%Ul(EpTlp3-p=KBHk|n$FpI(XHl*w~A(D>owvhi}t*`bGWcQ7>yvKxcC-@f*A?Zfj9wb9iY3R;Ew3cG6ZuPlW zvX*txdyo~?zuf~dGSBlYEx-C2*bI}`7EZYyE=NwG?kO6v?db$7K6z+Ba>&=9K<#XH z$_>4jSA&vOo~}qnyCrlcciy7es!ndlzhb54Y&oxuTaSEr+Q%e_Oh8@xPd3^=UV=5V zh$bT}@g>iKVJb8K>Uw`gB(ENowX2*#?;#rn%mrSGerjQcn# zAxBYE@3>ZB(ezu^awrD)vQt}y{xSNVTqo^?Sosd^YiRhQm}U|kNkOSDPngTbm|1uf zthM@{3Y2x2{Sopve5;*6eFiCD*_~I{6H-)ngGDtdxOe5gil2&P1j@QsaFIc#TnH>2 zATc<&8MpEPUQNHOO7c}zlQa$o8-wV1cw(6r9+BwLERacA>a_P>7zQN^WU6 zj>1`-Vlf4n_7|~6RcOikp5}#naynD#sMZDF_d>pxiHDko6LHSZ z=(7@N7x|l8^At!Uw{Z;m!MwD0Za$GZ2~_<$-5VcNrUtJPX#=E#f=JJge;_3*oo?5x zmsZ9!I~^X)II)%j7wnwaD_`cG$&Ri0d@8=dwozFRatYL1AD1}KSqGBhc&;Ar3_)_| zV&Fq538Sy^1{?eAh2+B_-ySJgV;`_}_)tuCCDn#DeL5hn=Is8}QWk-TZaFuXR{K#T zLJ3egFzou!DicuE5_~ANT5Sa{qwMK;xUtShPY-(Z6`OAnXQZ2PC_A^Cy7sb%#CAFB zPB3^Ut6cMaTp!sMVPe~%6TBa&bHbNBnj-~`^;rhK1np|Q^bPnA-$91S01Eu6YAk|< z14q{AIxp{HFrg~mg1AC|pgvSGD7lZ2oYcmE!ls)MI*Z)LC#}Mkatk8|aVBYNQJoRG za$mz$ub#Wokj^>IFY~)bD@NB#!Jcz)pXP(7w6zn{dQunI#bk;do@RezCv}jV=?s8Aw%99Z0u+RCCu25=}|#;f$-c|c} zM`%^F@q@ac<$BsY^|o-BZQB63|2^xZ z&-3W&xTGetqVX2VU8ZbwL@}7LW>=b_b7l1 z@-Ue(_x*#fT3m2{10RTsTdX!V)|#pME>s>$;FmAcF1Tcs8+WXbH;-Q^GL>zX$1sW= z#Kf`n#44e#@Mao=e1#lR{wp7ko48oDE@*~ow{EBI0#Z<_Iy+`k07VNtJ-;NH!YZHM zHLgHA0AT~bUEXq~IW-09ZJkutxVc_rGO@=PZ+e(DHN$Q_mEuq#k>8bt2g+Nmot=K= zObLRU7I@t)NO0x=&X@3X&Fya2Ux5o(92p}a((XFOCqg6*4DWPXGJl3)Qt71dMc_zh zZ-QgG8TK!A_YC-Fzj)2QzI?gE>Yi4?r)PCgW1~q_Vi$SX`>U|>uc=De+k&lS#ct)n zJ1(s&>0K8g3*T*6C#t_L{K7i8*hTL&Ya)u++|)b+e8u-Ras)2}%4rz`l`(b=XPk!P zBYeVSeN585e>%})@nd zRFz0t>7v3$MA{4b_{dMY=P9@!_w2Dd7XU>!0+W?Ld)B^9$F^hi?qNxDWYXjXlTt>{ z>}b@2Jo)Qo(t+=a_9qH<+MYW-f_4eRga_(rhz1>6_`)e0FZxlQe?nQ#RMqf_fqGJ)o%rheFkLC1%FX_oud zb9X*gQBu3n{YC;I95L=Yv{afrDbgt~tTvJhq$*(4>I{cx^^JV3 z!4PUqpd931`k?11#XC4Bey`5o(Dk~kvWA?xcNefIT%3wxVS!elAH0PFkQ>P!Y@T&5 zesnjyLuc>d;C)-^iQ7*@%SQ16gzU85Fue01Ia;@9n-S)D>^FCrt7eHk1dzNUQ%<#2 zmglTc%H0cH-IvD@33ZR$>+J1(Z|chDZOGlSQg!< zBctl|7fpa$m~k*YJzeY*Wb(~0e9Frhf$gH<(Lj{cLA{=UVZliXSpG#`$n^-7S-iGb zYN`vwsvpLxBk~Tw#)^?aVdCIzGY$sfFQE1y_9S0TzmfP5s^r)XdyCrb`f2WqcN7#a z{HK?}wrBgQt+ncoBT871kN%*1>S+X}FPxeZ7jRPVp>6!rL#-quoQSSuOOVoLLOYCZ zqB^^aG*sNv)Ffdzo7&mw|PSGH$3n*z^SCF$w3#@hB_h{m6J?FIW&(Qt3-YVm4i-q*{ubR@8P13Nn;{xE* zy|?^>tx!VHFhD<~VCg#CwpX8vYW;=mQ;uI+8UxX>vtU$YD*P>?pc=?<&F63m^ zIN5q>aSby63NT2OMa|93z7g7|dU&CZ$zt;I*=N|1(kXx=I+Fnw8Ns9h0S5(WCn^14 zjkt;ki0c57!GHxo0T$2{{vx&}Li}I_c=ZH@kfJLH2`lmJ)38w@mOHje8v5rZAx~{M z8^b6C=HbY{u1*Fgh}d8l;L$Ko!%q^b#1{l1XI5CSYtIv@6xk*4#3-Dqtp|=oL-9|1 zqsC1YI%9tSTEuEJT`xbbH?>G7=85YuQ`8Y8d*1a@oRpdQ2y_G2H@o&)>fkWlxNc8- zH)WARZM^+N>Y(_eKy75AoLNMvhuPo;FPO?EFUvc_622u#RCN&cfrX-`+;3-2!zbk} zM$6Nf)P!K=S9Ar;f`v}9V>4$38W*VDJ`kBKTc@WaPJ##(oJIY?b^4iw0;Wd8+TZL~ zZ%G$~6z!oKshd*=C99Q%=t2=9u2%$Gi^@aa3rPbPCk@X{do@kyBfK!2v7R8xeTKuY zI!OvWS^4qNb(4O^z(A2@BPv#=UaI{~+42B*F3suEPVD@*bv;SZUI-|1oEBjWCU{8U z)+olUxVbsA3~CJDJK>|}&Vv*0aPBKZENY&$Z{yw<%slQS!f{aJK{1CEXM}QAp1w45 z_e1^c8=n)ty@T70!u{tex1aZ8?~PUIUvam?vJJd4+29$(^S3V^t_ceq*B<;X=a~3d z^J2NDVCaY>p3*$g+kd`vN&EJvP39P`6HicC$TP*Z!!}@|a;Eh;oKzf^?+5c{1t`%K zj4O;k(rAQs=xfJXd14ot26mp-PZv?JU6JJ*YqJA^yP~vhdl%&8wn);8Ay(Pb{wRNhMLY! zlZKG;Y|o=}f%jR@Hx^~52R*VX=qSh;v)u4HWbeHro3h(yb)^|GK$-WLYdk_ zlMDObhGcHzmJU35`K+tjM2q4=_|g0JxDG|8x3{NJhq{lj_d_vm?+Kpr zL!Qh%ewOaN39fz!?b(?}@A`Bzfn0La<0kQw=FbkCedJ#JkR+9keUAlGLbp?FE&pV6 z?KK30aA~D{Yzx{B`dU#US#*Ik#G2w@qE?_Yf0xRw>3j29|A05{ zGaseVlG5h~)FiB_#Y7gze$g#>Ps54=Docx2JXCLo=J3NWtQv?q<`#Byo{nw$t8usO zE~|Jy37Ml7hf@S)u7zN&1Oj)5Bi3Du*vAxd@^h}-J>1ywFIN?VK&YQgCRd355+G+W zQ)Bhgu9)#<4mLi}uHxa)Ig5ID#Ni2jg|OfWbi1v!Wp_WeGl#RXBa8OKjRUkc5F88L z(>DRqAu^IXdspPN`SEGv()xLQx~#(Or@3JoB-VTppSx7JR~0zloIlCIoW^;1YmDW2%$tY>_`;(nJlmG*+P zGcpo~yDhNPC?1+XeSC4cAp6xY zdXBu^-Y5M!cv<(s7~wMfY=QJoy_C`QR->K=1sBgHT(~_HbvHobvhxSoXdjVV@V)|k zkM`i5^YoUYh)Q^Zgosa|zP<-+QYNYQ$cLTDGSG_Z(p&z!&+F+ZaWsjI*?n5;yv%VQ znO&`Cot~bFkOBe#UMIDDVC)&}Z{gC?k|z;InkEmxCW6w}p}HH#D9a72B5jUSH|mdq ziCY9~gtddvQ#S+?jIqDb1ZnOSR9TDwF4(XehAK?t=KO&|fisz~pbI^%$_LZNFJOq3 zmyr&iMbZKInpX*)@9F_??j>Vs!3x-jSo(z74c7YjF)Ijh9(@!wi=6uk?e2sS9zTet z2&shxW@_BvjRl+@0(V$`c`&ni*J9tnkQx4^rGN`R(X1=n6>Rl_ zq5h0it)6Jfq=_U=M{e_v=B zwwgVlXZcoO#P#4Y%D|0_55 zR-B#MV}jN^q*d>DjqQ}4j`pDJ7ub=zT4!=YFJwJB`r!V?4;zNUpn4Ci-P(~bR@arn z$s!Chue)z=`AdU<+^l}o>=RQNr468PcV4evhU#e>f~~R=9NS$?emO?G0`l>b{oUnx zBZ#@vDIG*IVb~$S7%n`P>^K>4`C5Jd#2^JUaUuXpptsWj~ zim38#9bBN{c!V(D?v%g|(XRt&qJLSRz_7gh*VxU*-+R@v`havAgb#;RPeO`mhNt%) z;@DAJmYL3#9QUz;V{^Z)wDj+|=nsn#t&M?EyXGyC%$s@7X7X~rPxVhUyY<*7xy+fT zsdcY;f=GwrFj1*8>?jd^8PwM5(LS^Fuy{ zpX?NPEX_|X6i$BA*sN7n{n&Cm6)u1)SNdqGsfH7cS4DxuODZn~gp~oq1a-!yh6b-% zME~>XR+#DfyO=dVP3|w4U$m{;MfRKu$PW-udqiIMHz;=BkbALKu>>q+^$N?Bsuy+E zUPc~S*#_B~J4L)9W-j5jTX&`OdWgT`JzgUL1PlIg z#(CfSvN{kWmOj)T)s-SbvroKaQ12~A=|iY4AgLs}Dc=6$+`-46H=A3S!dea(b6CrI zddqdtp~SDxHjN2WnMrLBe{~GF0P;DtaD7r(mEt z{`nh(kv;=$2~-s~u2sYt)NN#BUEW{VcA`%zdz=t6KO>M56;vjJ(52XoFqg@mXzB0Q zX%Z2(6b6!AcP+lBrWJEYdu%#}YuEb23fU*=Lz z>ktDoJOv1}eb0rk;?Ru+n+-;OijjES;fL$?^Plx(4eB$;d2HSo%%stiKQhe08FQRS z*(`;8Q&>T9y;vzv3`<4E9BywFxzp)$_k{9+;rVyo%%35%m~99bw_iWUKkpCic5f>9 zs3|MhsmGZ|wP0}RJ5DgkNfmzF^6@UW{C9IAZ~Wnsxu5T!R&6=Fw|2L9lLM?qO5)Q z-gG$XzMS=MsF!lk*;wG1WB4KYQHL>ha0L^H0p-ch4lCJ+h*&}k1)HD1lqB?x!>^jm zQMb@e4moxmb&3tlF3j~S8&7W#aQJRVQb&z039`{2>t%TSAbBTpdi7sAnO!xG%(^fv z$iR=(pN37){y_DmfK=?B*dqgCoxX*Z-EiCwf9D^CKWAh_E@o~xJPqH#^<(zTrq7nd z><`XzwBIQ0qIY6%cuq5A$Xz@u^uk;}&2IMfSo4N!U-rr>3o$&5wejSk{-n>m@~I9E zc>g)}EA*I9#V;eF7W+DFZy%hxTzX~kGdZtkoHJhhf+sU~KPKk$g)OWx#Y<(EL=7$E zVy`9M)zsg9Tz|x?`jSQXGfEn0p`ZfLpuO7r_DGFWK5mg){&bvwjYe_B!|cX4TNreg z$0~b0f8+t#Zm(VQ{($sI(+Bw43DdXUfn(1AhMCEx)K?T6P&NvFpw{lvmFTy)b?UK_ zVaJ7c1i5S7=aOuC;mKad>xLyljL%-U;LZx@n(OY_l~AnQgAUd%;aa$Y9HLnKeGWR}wC)xEGRt>^Jatj~v3vg&PDR?8*yYn%g_?z7D;$ zs=fIm`Xb#{74C6T>#B&o$fJW@9o=fIj^dE+KaelW6w_5R(+wYbJ=XcbEyqQ?Vt5&+yBh_=f<|JS6dG& zt!sQNZ2%&s_u6&xBYX% z=8v`~coBDP&poM+EXd#8ErWdV?sW={Czu{MxX>|%B#1X_v7C6{FK$d;u6rNLiKO_?I?L6%!W$Nj9C)t;I4pEgBN zl$=WI2N9~{XI)17rk5@Z6m_+MdROtZP+LVpS=7s2!t1xn)cdCT$uoLVmz1SCxZ#eY ziz~fix0e+!)-MNhXz+GWIPtLOWI@u+WG9(aSR8*u8KH7R@t&BNX080Y@S>jLtYqdp z$AhEKgMowmj1#59Ipt9Ad&eU2@L}TF#t8u3xC!L!BN<&K+@s+y8maI^;;4ll91@Gt zfpvh0M6p&-N+w;#62W317!C5^gb-YYB9S)Um9=|~=UU`|7|%=E(K*!;gMDgS>0N4lI0kP zvxdPA-EQv7ZJYrbFo8ro)IE!Pfok~#``3!QSPq`FBJhKnR#OdYakiff7gjg|R9nO@ z%O#dPo$?2=Uzsx%({(b|V&kcVTd>E0y{p`Pz(f$Qi;(dIsUW}+j!hTREoI`rVIM=P zJ&1H(J-ZdQR6T~$66io-A28ypJCJu2qZU>dssFbFVB-|TPHCh>sJ0?PV&VY77q4p3 zzKiO(q__Mv9Q!*7?NSwbSbcF;2T(#hTq8+9#h72us&7S$vIa5(?>)`+N6UScDgG5^ z5m`c%-O9B)r|fyCj2J$nxLW$LO`w4qzp9G|RU?lfxq49TQ5Uh14vIwRrXq8ozyU0u zgTQzT+WQ#;DoJiZ{l=gIL159tuDMS)eC^E$|fBL_NSxCIi%kf6kPDvVnzsPNq?ZG*|zT4p(jm?zkK9 ztCejBj4c4^=)Y~A9l#CbW2+szPu6Z6a*Q((X*05Qlk$1I_q-ohI`*@nPUgkFpujHy zHx=xUKW&sp8y#BSQO7&_cqxaiTH+GoE&ay;P zT>lm}Dc#OIZ}QVv=b2hp`tjGcw$dgtcQ;=DeQ6!}{`R}lWu6R^y|Xd8O<{Q&+mlW) zG#(GnPH0`qT>GY{LS}cZ%(JG87vHsc@;l7cJ^4fqO7H_O#yc9Mt}y3-ldLt-ct746 z?QYPqv+70B4xczl((~UgXE)cLO-+6IieQ_Qe1^2X8I^th%`J_3x#y((5(1FP3b8N0 zlvz|(SQs2XA#|oZsX3t}O?#`&_1t z1Q2t*mnmC!;*7gaL*QQ@g=Knq*Eky+eGBTXq_jjx|Me1VUDYyP1MBSo-&z&0B^LlHnxWF<|2h=n8gc~DxW8mABEr)6*drEy>8n%){(6oP zAW#i9BZ*@IO!!sZ$*GC#@t2UsRiq2nvGvD4eph;OY5*0F{OAAw zwH<+z|NJq>{088@J&a^p01+5d)n5Z$i#mTRa=`zk>47Z%#=qMQy4bg{viYC?{jY7% z_|>~?!b1YB&~1PBc}-Xvr2#eckDmS4L;t0@;`tQv*sZ^HWsvyr1GAy6LG@pc-%oCy zQ!jfIQ48}wnF%M$)DJlhgc=_18sg!~5^oowpV=;i(}c~!3J0Xrn-(y@r>IVO(=a?M$k6NA|E*1YX=HyQV7dCZhFL9R zeL+6J9pnXkd49A}QzN9lelrqK0al?Z&eL*1q<=?G7y6rc)TlgfFiaGb?2LdWuyIr2+p}g`?aox$B zF2>6C$6%v5_6X2QTe@b^KH}CVp!nx)>^Tpwu_3@b8vvx8hFrZ9!it+-mO4!V{0q!+ zXIT3CdmOTXE-l#uv=;k>GW!7iQ-2x$2m18t>YJrI>gAbC(x?V+hooPviy{g20Hj)e zu+%)m49BGH`|K;~h=|_6UXvGs5@asYCZTPHvw}EKK_)+YjTE&uM1G+RVy_$ zRi2^m@Cy!y%XqxVJAT3nz?@CZvp3l(o9@Y-cPEiN?FG;50`$+lE@Q_oN zLd+=)!`SWZ96p@AlY3wSyT$!8_o$7Sb~{~(9!W)X(QrNxqP^RbC%Jt7<28RwMAc^E zp3m3h+y?V^Z$A$AYLKOxi9+8yDc8B*ZRYpx-=&m0a@TSlMYGp_IzJ(OPlAQnPo;?C zP)jL4^0UR&>j^^{-DdgXnsiqs$!Fb%h9eFfzHSQ78eH$Ni~s2Q52O9Q@17M0r^n@Q zWTrl7_8EF`IpTP)ezktJjVa2IH2duXJAq+&Ub!hPW5wD#x?Vm4!y0b{_t&j`$;$^w z2CopJ?W2Oe9d*si4d7#Qd3f`v8qYVMZ<_m#LWlPk+=yc&64AfU7Eg!{WjtKUT^5w( zxGgW6yi|C?H`jWawDJt^HXd?|Q|>Y2@p7x@;$H0bHvugpcfx0H_$%PV3P5}8-L%TF+3 z4Skz>F8h-rcO7sZu9>c`y|E#H0Vie4`!Y}6y<$-{y*Wiqv|G%pcEx3OOdojyTG(h- z=VqAbSu!kP99DtlW0N5FF}vW9``TLFC!pX4p$K&QhRqGky4$L+SpnfGFHc*3SM8$E zE7R~7z}Iy9>u-r?Hfv>8lH=~4+=T8M9`viC%bdOZd78U_sZ&6pDE|z)=rdpyX6!%j zIl`8o>WWe-zuqb}2+W}mSjaXSrHb{HOhnOs^voM!CbZtRhhyw%GlKV^So6NWcfp7C zz#K4$@4GVC@f;sB^etZkI)zJj8HgjGs2)#YC>~Jr_xTh@S0*W@c@s*P8{|(5?_y)M ziyJah#d=+OZ*0K$1I`Ilh^UPBA$E# z8ffF~zqzSa^U7G&1_D-Ieo%2xQCz0}`*#~F-LCV6cn@Lwo)q`Odp2awM4m}dx|E2zVg4E!ww6c=>_R1G6`t?qi^V|(D-I@&X zuAx2EPb)fa&B5#{ECZ4FthF7e>0UG8v=nyV_Q6oJ;SGkdHjUX?rUo?gP$tA#)VFzW zt+?9<74m>*7pcGhi`!tZqLGl$uv)dO0!RhYY4W#x#T}!U#WH81zPMC>#)`nD7z17B z>6fZ(O2?5R>%jj;d_(s5GH;F` zg%U9@ik_uiDilg>yUh_OV9iDghmL1HFp`f`tC{OjsKX9gs4(>8&u`AY*-`00OdKd< z8L#_!OeNLvSZ0?;w;!t`r^ts?qwS*X1qhT*V4`X&Tp~E)dGO?k1i?~@ANK7G`42yz!9(EAX;TXuqJ}1sT zM1$n`4S?7K3i1FW#h8=~MrsIa8#XY+_um$(ti?Eh49)(oNNb)0Sb@g7%9&Z%kf=4b zQ#>2aLAKysN5EuOCBC{HjO)9m?H3S#AZMu%v_`!E3bS3t#;o91XQ{(@3ZZlZik>kAZVFlq@)T*o@VFfzP-g9 zlyQ+sB>Tc_S@Tze3PRGM>iu8l%#Yp|o2pk3&qmcI;;e(-KkZ4(AEyM8hDL_{&(+qY zsfMVIk^>p;x}NVAKXQcW$e#xF$7cC?`5xDIPsCjc*mOjlw+8iK>2g$Dd|Fl+T5Wo& zun!klgUuI@_IExc5qxv#&=<1;r5UQSgzoITh@P_EuyDG$AcHI6nyZl{R<&Mz>bpde z(9oj|N!=yNO7T7W)&k)PxutD1BiX@b{3h9}me-3dHQ$TKL{gH*3mYzrztkTnNR>^S zB2j6akGWE*Z6PLHti3VYzXxFjnnug06s-+yoVewM9WAwliSZAR*Gen;*-_U1lu$aEq@oZ(HqVqRA z?&b4Tr-@r@ZgdtNh1o3m!_zM)BjeLytlaZxN|@KcB{|-$ViwzNU{u)BQ{6@8nM*hj zh>+wp0;0-eKS#7TgRw?*iWYitcEs9P%ppVp?)D!m1!h!EHx$Q;S~9>QSpwOI9QB8*61 zfki~u4Avy9;t#Zel`i^OF_Uy!#BzM)>hj{C9|O0NE^bBW4A^5PS$`XfnoLG6d;^fi zUx^E7Yce#vT?jbs$kiNr!miT-Y~|~X1kb+UtgO5ashB(NmMc{tq^pj>Uh2DfOA5{{p z(U-ZtE3LxfpDtsm+NOwK>L|vGbojqEp1ZT2w*XL?1iBzN1u|&_wZcZ<8^9VXn2 z_@7RN9m}jzT5-9%dre~z(PcA3etFp_3`<6vu4B6U+}mOVncfX9+#4AisN0@+ zYyhj#y6zv%dGf!|h0Tqw-u2T|?6~P>{=e_*w$8I3xX}?ixLc^ac~6j?y$>CHuP!IJ z1d7&mpxSFbs0e`)$ghf|nGCY*T~D)-D$ov zNHsp{h+qrP5BuXoFnXMWoK1%}VbQIt?N&U?36EK=B_q-nR|#fM1;kfC76mdQE%Lx8e1io{dEaIMEgJKbOehJLQLOS%FqL&on3eUfhmAE@i{N1r<0Nws`Lj+Pz7V> zwbB^D8|{BzRWyRcasjFDaX0}h8uK&^u<;TB0vK#DAXq^Gyz+l-3v8A$1=h$yvwXrI z==PX8%Te;y{=tD*v{(-+Rq8$h3aL) zt=o;tp8DPr84a3ToY^$tJ1jn$w`X?Yf!?K-!rMs4<{kWAs<5o}Q>Q@B+_ugDkaN>Am-F|18(8Q4^|*$6wfsptoSY{4lDRr*N#ts(ru;FB z7wvwp?xq?2I-O}`t-ZV2CLb&O_xnk{hsvq_k2hTJ=;2|>sVGzN>P0Xoz!9wi^N5YP zW1zyV@?B^~z0n-_e(^(5rZT#k&7YGH^HWr?fz>MPsD8%}=>U{7IcHZzgkU39pz>pn zI?xM>$kNAqF0e;&u7TJf(f_hN_$($o>Tk`V)x$aBf6Eggl5|O9e`^U{RXo>EO#g@O zP{jY2<1hC|@Ufhqf9V|k|GrQ%YPBzai5kJ1UV^;Vxm6h!Xg&*|GN`0gt)wcW>QkqYM9Kg}VI z2ob?pZ?JV&#*CX^xn1zB2=s@br~(Q6HH*$H^37?$xUKyl4mJsbzM-h4I+gi`eFHO| zvmVvXKTWECi_KCw;$yC#ub>?r$0I0L0XW3CM`8lB2fp~~y7TxLSzrdFrv|wql?L`r z;(0J$ep^gz;>@!Byp!~#1L!?LBHSz{mUy(%RbydG!mpk4;va)zz>k@{KsNkfl@SA9 z3W(pXEh&WJ+(%H!Y!{ZQPGzCZGIO+NOY#lXpP{A(hjr;ud{n$t*liL|+ihNka%~R5 zn@t3GEtAISv!0pp6vzu5GEqX!*Elw>Ui8K7Wlsvv@WUnJjyg~f4qCfVbA~O>Oxe08 z{A3{|^QzRv)opc0K&-+V<9xR*3s~?N@NE_5A|9X^_b6oc57xVs;NZd)n0#5_gBxaq zbLpFLX_ObdkcAZVmNPj(KJ+_^X?3@`m`E)pB4Sv~;8WRBr7aF8{7wTJ@YpM@NGqUl zTEvLem{k1kUbW%9OdhMzsiaaDdQ)Ushk)at*VKIOb9oYwHgmIbvn-Lesk#xmd12bi zWKM5qvu}a{tKM8TBdIb@Eb%hGz@KlYz1}Wk$GbhxPK!^szm5XxAt<%FkOZ%-kAOJ+ z)nN1ktgGW@Cgw_i>a4kP$$6OBT*6wJ#ct6vW8(10yWjn=UdYY8J-5lf)mZ+;SDl|W z1mH|=HclA2D9 z=j)-?kOYg8IuY0+m6ca{ydOZBE8`QPy&n&%ydR+R!(FVd`rF?+Bp1*(pKv0<+PKskWNR} ze$xTTRBW*242`y;Si(I2EM>p6Q(949Pr1S2?1}zMhdt_X)}2NRxL$EjhBdXY!jCt( zqrfgYU;h4=4Tr(2--`Rp(i7s)!2@2xdTmu{KWG{2bjPv>7o&v53oT9u>A%VbzVe4%*!{`DO({H#YoY2{>85{ zE$%MMfO}XEMR@h|7^TC@boq7rSTv8c@Tsa1^{aF6%Q_#A?cU%!9~Tm+-~~<1)L(om zSK&_{#X+7n-lZuDPRR*qdWllwEI|m!(Jjf zF9Va1mDr{Ifr6kDsKImm0H@nu8APk(xwD4w@2G3yzxxl15pl8+ky7+y;T z(dy@cH2Fv6JXFJ9;*Hz<_(7?Bf>+p)t3Mi%y8PgaCiy_qQF?OQGLH*J$*|45j}|JS zwM^|P_D57cYpAXN8XG@)SZ76@ux7CBfW^Z5-=D`iZKALY1^?OkBS8iVq7K@o%aBTCBC4cyt z;Hxg)0jyV~d`As7QY(vA_9J6KFc_4u zB)BweIkzv4p)xqk$O_?L7QUKgku!UEpBFE*{`$S=pj)|HCZPL=@p<^eZTmd8_Jy{) z_gL6qYX?~4SJW=;jInVt4pVzD9C|q}KB5XIW`T;v*3M1Cp!pPN z>a#M)XTeRts4NdK9p?22V`o~ZknghGt9lQ?K$jG3G!2k%zRI0K@un19p z&HE3Bd7;uEjU~Uzn8kFFm!gQv05+jqP0W*11Ytb$G;B!})hGpp-9^uvW7R45A>^-l zj_wF3tOsa-L|_9a4iq<|@`smEr;w`_Oe*8i)U5xDy*B};s%`tm*KW(skS&xkTOpE2 zlG)Z|%n+3#R4PQqGP9Ja5F$}wD|0dwq72(Clr2Q&xorsByxBwlYuEkU&+|S1_kO?k zeZKcQzVH7(-s8BB?q%(@u63Omxe`9PO zRX>*TZzTFKECa-;%BHKsfvH=UWbhRrk0q7q=(@VDa}F~|h3;XxG}#e&#E;e3a^DXd zHDcLZG--21OaNLZXf)m25|{+vw%r6KlO!a6A?nTeOe!p=xt*egqkW|&P$dz56pnIc zb&qILD$}1Qq5n@GAq?75w2sP7)4cv~d!-aO1YZ0?4S^(ZS@3Ou6hUJR=~L7ONH_pT z`vP(tiPOV_3>XQa6aN7#r-}M?*<&ch<2kK?1~S-+hv#LdHBrf#KOt9G4^o>`q(m@o zv&RoucLbtU4d`R^$B=iQ^E&TJDzdE;F2iR)!oTlEc2#kdM5ZW|F2y(*V2%AEUDQ&; z)Uo(*YC)s!63K9nHTh|%HQM6bV<2l{ttVjbfHqc*o@wPc!=BC3ZHsrD4IQ_t0srCB z*s%O;Qr#5RXt7{Io*ou-XcNtV{e030VxWZ}d2}iRBEonLGF=R5$>l04A8#uAfjczEANdwEtK=c1@oI(_e`MWXwlU zar73v5jME)wl&yHt(}FOew_;3{soTvm6Py6b_u3PL5ip7z(&8W$vlMzp5GtY&>-&y zmKF~Qz65N9AWEK!{HF#)pQ&2hzg}7vt~^JK(y_gFUH8f}{I~|A zh<&|H%*0+-sd>MaZVrR4*9$%fzTAbknPDB;tY}7x|%!__O_S!I&5YLxBv0fi1e|IJ*fG&dC~26;Pti?mR#q_%qwc#Z@x=(e z)lW1&_RgW)-o|;8+ON+Ye?lke(LbSL{rZCK0cmwr5_P;|p3EJ$r#>kM8ONqNHPN=Ckvi{B}D4LGYJwciTlqWJ6y%vnw zrVhEsu>1QW`hG6(BN-m`dQPR~(QPG)w1MK&6gj{`|`r@qg}6=Z_x!pX|fm zM&-Zz@OyR`Zwcu@>BI!Efei5PDyHmwJu5F;3)y>t*-hYKSUJ%D{4gE<4`2G9Y4*SH zT7<_mGF=b5fCHvlFX*n=zw>4+=^Q5+>&BRA_GnTXYdGqQZvYRpTGqL|4$4A~`izX1 zK;E*^DH8mV&>`0*Cs2dPDv1?Z3}=|s^fG@7T+_;$$OQLBpih02342tzVCim+FzT8z z4}!?s7xZ8lqWd=iXyO}cQ6NH}qGxTiywSepCuFbaGW>%6GLV~ctQ<|fjix2GBWM{C zt_&(5Y;iino0<$tbR7PboNviEf=f2QE{_`|-USK2D4Jc0g|Q@%u=fRq>(Ntn8^%bu z_}W6Ox)Qh(K&Rni>q5~*!Zb+9>%z6uQJbLL_;lw&=R)Ze)8c@UgeG~ldJ5(k+{o}( zu`Z!|^!s`vu$$U9YY^arlJK{VSJIbLwC4JF!G;9dd=V{>W7z)G_pAXZ zdL#PX3gxlzR{GEg+E{S53q(Fu2KRWcf0w-W?(DiQ}c z(1+{J){K*iO)`;+h-18b_;8g2yT)wwecz?*Eb}*jayN#=X7XgYXxx$P2Mv*U~ zYRsbh!t;*1M;1z@YzzruZ3oMT{ z?pVkFkw2B0$EDvRc_@*%1a`%N9Cqo)o;>Ulerh7QMddfwn|R0xoz!(K;3ZORW!t&b z!}dNr3SA~VJdXPgTL4ZtsMA*>lG#fKl@#N8yj0&W&{0n78k7ypnOA|bmKG-;1r^$2 z==rKXi<&!MbWS8WMIEfm*CZ5F9(-r+4Oeer$=f0qrBm(XC7Ob@8m9RioCI?nh5y9i z?|mbf}nd9*Il&zQdEsmUD|7j zPfa-a`5I?NII{!|wa?V7h;eQEIB_Eopf>EiWkgx_o*b4_d)I+PRJyOfdm|Jzjw-BX z&Ip?`Rd{?=+UZQWvg6VKQRJS+GAWpS?rQmCIjCh(Vou#P_k!-tH>m?c*E|Nc@iATA zon^uLoA{1mt=J>Sj}i2{xmnN7V9mBBQ`lnB60tDk@D{qsr%zMv?G~Se%vB$ zdXzE_Dnxy-qnD7^AGQtb3AJ1v-~wzzY7O+TO*zV;w9hN|3791>v-(&HIU;BjB(~wM znl0U>Q~y0Koxl1GK??xdL1d+iyihbn5#=ZDTDM_M#U*$s(=z(p07qdDSI-(j@ZllFaCS;)hu z6dIzR%fw|D%Qd~4USAuj^%J6mx>*UEsbFLaK{=_2KrO$cC%eHMX_M zaZTLwIz7%w%RF)Z^`P$SUH;<1X$GcQMjB05rKI$iI4)$|yC{;PyyqFe@ctL^0?JpP z9NjPGrZu9X6UF-B5c_M^LZ%(okBY|zt|eXH)%6n46#wWnI@|dr>y_>19>a90+MNuq z*V*8kra6XsE;%e2l7`$7u8+lcWq`aeu}*_bZJolu`l5gGDgC3R#0rsB#}b?B(cEy) z4~gZsCD?9!301*!#A!Eh5$fg6H(oWHfqDz|4BbDMcmI$MNOqgHpy;vYMxAQ0<-0?nb0FhRwbvWhqon&G(Z}@3Qn>TibkK5>1uj#TvWM(^ zsXcy9SaM>6Q$F~tc(`P1=*TWV`H>vpHvy2b@rd!(AK2=FWrt85G%;q07!wm^Zs$%j;$hlw_AJ z>1Z}QRvSDX>henWQm};Gjmw4z%euIse#HX)gR2I;rAt-Mict@##5M3jZG0REDMQ3=rHQ1T>Dl~bg~|`!o)#n(H8@)i;!`Iz(!D&z7ouNz3FDTk^v8nf}$JOqBbDi5f zJ2?GvxTM(M-=gx#X6|a4Ux8<)QI=(~DaW~Q98~~av+@d2ik}Ii;E7Z~ccc!FRz+OT zHvj0P_IksF(}L`ggFWirhUgE}71=#~*j zN?9wr@5*kJk=rQ+I-Dr%=?h|iQY!A9S*HCjraSLW6F`2x1t6&?6s3FTA43)_DNPS+ ztRTK?9NcrmDXhv%BBf#im+4PWhe$yIR7?S2%$xES4rT{9H+3oR`V|9+wOS3-3CA|Zun~O&lrZDb9f90!o49ODdpM&NOxQ0z z#VtTQ9$)>o0;I zK^N#JsCEGlK-;;Oe|1)bm#{{|ZmvL~&e>GdD!}0?QRv4(cxiS6y_hx$<5%pEzq*-5 zLI5F_@9!c{U1WrzQYLUVU}&AsItZE03v8GGKY_pZ< z0Sv5hhP2h`yr0k~rse1yYKRD0btWJcJ`Tc?Ypc#3&poH`n6H5;uB+IXvg#M1TWj40b0VFYvnN@09w==ayoXJoP*Qtw;~h=U(0Afi$PDxIl0Q{!Em zJ>ynNsxDC|!u>-4e;~J!MnkUzty$JRpGGyH>HcKxdQ%Fy{O+D5GU1KG#%TbE-V(S) zcSd&;a0dNpx5$8P)D+^dk?Aw&63%qv*>4)5ErINh+8=?d_(fkdK>QLppMMpHid7LZ z-ZJrbM-9v0+kwGEAZZ^JT@xY2pt7O@_H4rKf9AczAnzTXIT^jtPfLTB9t)lL30=dZ z00sO@)Qr{IDh)+8n@Q;Sfk>Z4G;d&XD(w0y{MOCUV_QK;bxQ|U(olO_dc#^jzF z9({TK7Y4Y1EYSoC4oY6KoT=L1j*6fQiZ>2CT{6SD&2MKfAa)O-ljGV*N3b zC-&ggtsNN^&wo5KEXjR)B%a#Ypn+LLv9{cCJ*)BlNsc*x%67fX-TV5&>yH&U1V8_x zV6M^pdC4U2+zbN)Oq2B!SzLR9K|H!a2=nSb>%FM9z1}@7xJ_tutJG5S8&+pzz0w7R z^TsKb=f+3FvQ{JOq+7eX(4o0w=o!twn>g%oUElx7{@|79qTdMQ3S`Hb{(aW)K<|O` zFO?}3eUC2PS~P}vkg!`O&(~+V+qTQ6XRRRAkjhp!yte(mwTCJX-e?K?-FdoF*g8Wy zVz$aXP9FeSMo-^^DXv7V-{PfeVG_7VpHed=%S7J;H}7#tNOAj29hCz3tdmk0XG}F& zh#2_enWfU<&36bQ#&m<#(~_}zg&C6JPo3VM&W_u^zZ-fAM{{Ix+=JI8f@gi{l9NhTX1AZK5YQ_fv4J0AlWNA3)mTeaMl<0 z#_M@5879EZF+{2%UBAhB}99cfIgH3uqz<#fgEQZNg+~cx?pU5ZAlDGskY!;horg#dHA=F#1PWBW}scR zY!IuA0GPN(2QsWHhjBnC3*PgG37}xoua7-n{!0ln#{JZ9q5k`A{`iCKcY~VDZj5`L zL264XyD;&Xo>iCMN*2tRE`}QX2c>{YUd0*%;g3D}OS6D4S0Fq*W+1|8c#MFJ0W)dK z({Jm}S%9)-+l|fWYS_f?kFj0+yj);a6|}j-U{f5Z?z=5s?@Ldj)zM5go8u&64>Cza z?PZ`UfmMfnhFLClTeAc{AX$l^)ZC;FsUL^QYuMG#_=F8AVGQxTcGD7TOgSli?#oiT za4^OR_>e6!Q2P{7`hv%AOEu_m864Rh-lX2Pn^VAqtI*T3WS*;cYC7(^ z={_T=aR52GW^lJj?T)m%!nxMxPqh@weM++k`FPbYB<#cd3W?mz4ED%KaW1a(pcwb0 zd)u0Y0==tI;fA!`0K}w9cd+!A@t;$Ya)}gu8UEHX!5I|uTRT`fmyqAvdx}Xhc9+)q znd*DY-SgU_$oVK`KG7qOZ}mEtj^aU4yZKK=IUo=+H&`&OLrITJkgl6JQ}V9xl56L| z@)S16Lc~p}pl5R-pmM0Yd(_amKj@Of_o|CAs)#bFK)zg*A@$akN5%f*PX^Mi?*dz@ z<^IYK78CQ;O9c<$l6@uPII&i_Pf|NCjKnZA4>lc)N|({(AK#rEyHVb6RA(gH!>>T9_d|e-h*BHI{14x_>l`-UPA0|lt4|d^TDt4Hf71Ts8yDlODVgg(95a%+jhH2}(o9c_BvwMc`yMWuY_@$fCy3a^9}T&q#n&jeED9=J z2pBjMI}Itl)7=a090#m>;>;`LrBVmryE{-;Wv7gl=qCzz#GT0=>%(-+5|WGbeGh$M zWfMKSnu$6!O4zBJcHHJT^Vz7UHOfRjA~r^&F}@Phy4i0=yD9X&0H&pR^)N+0_cTl* z&XV0%_$8z{G!~osjJFs`WEobfF3u~fOCs}v=;|MACafoW7H8>hnrgWX^-ueIM85%h z8k;2{a#s01^{;T(@$RVt45?pKl3IOG5 zWqqTm0#iWxYpjw9QPxYk*TF9N%oXga#dF*=_D5+cRhxcjzCxHVH%S8XpTL zO=;8rO0WJa$|?O-gobmV-(tvuVDHJKcs{|n)!E&_oy*AC)%oxd_hV{@bG47iYkdYB zHLqhu539tB@jtdvYT=HwWjWP&Ce|b}P267VjHltjqtEO!lT$(;JDjiU&bVLqHRr^R zPO zr+yNP(*@%K!?kc|w};O{;7eOCdN*CHM898Mb@a wz-=&tF=87JE1mG~zX0_54yP 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 z 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 diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 207f7248..ac02bce0 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -8,7 +8,7 @@ import { hostAccess, dashboardPreferences, } from "./database/db/schema.js"; -import { eq, and, desc, or, sql } from "drizzle-orm"; +import { eq, and, desc, sql } from "drizzle-orm"; import { dashboardLogger } from "./utils/logger.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; @@ -55,6 +55,10 @@ app.use( ); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); app.use(authManager.createAuthMiddleware()); @@ -85,9 +89,6 @@ app.use(authManager.createAuthMiddleware()); */ app.get("/uptime", async (req, res) => { try { - const startTime = Date.now(); - const userId = (req as AuthenticatedRequest).userId; - const uptimeMs = Date.now() - serverStartTime; const uptimeSeconds = Math.floor(uptimeMs / 1000); const days = Math.floor(uptimeSeconds / 86400); @@ -296,7 +297,7 @@ app.post("/activity/log", async (req, res) => { if (allActivities.length > 100) { const toDelete = allActivities.slice(100); - for (const activity of toDelete) { + for (let i = 0; i < toDelete.length; i++) { await SimpleDBOps.delete(recentActivity, "recent_activity", userId); } } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 22e9c232..322426b4 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -45,6 +45,10 @@ import type { } from "../../types/index.js"; import { getDb, DatabaseSaveTrigger } from "./db/index.js"; import Database from "better-sqlite3"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const app = express(); @@ -203,6 +207,10 @@ app.use(bodyParser.json({ limit: "1gb" })); app.use(bodyParser.urlencoded({ limit: "1gb", extended: true })); app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" })); app.use(cookieParser()); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); /** * @openapi @@ -595,21 +603,41 @@ app.post("/database/export", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; - - if (!password) { - return res.status(400).json({ - error: "Password required for export", - code: "PASSWORD_REQUIRED", - }); - } const deviceInfo = parseUserAgent(req); - const unlocked = await authManager.authenticateUser( - userId, - password, - deviceInfo.type, - ); - if (!unlocked) { - return res.status(401).json({ error: "Invalid password" }); + + const user = await getDb().select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const isOidcUser = !!user[0].isOidc; + + if (!isOidcUser) { + if (!password) { + return res.status(400).json({ + error: "Password required for export", + code: "PASSWORD_REQUIRED", + }); + } + + const unlocked = await authManager.authenticateUser( + userId, + password, + deviceInfo.type, + ); + if (!unlocked) { + return res.status(401).json({ error: "Invalid password" }); + } + } else if (!DataCrypto.getUserDataKey(userId)) { + const oidcUnlocked = await authManager.authenticateOIDCUser( + userId, + deviceInfo.type, + ); + if (!oidcUnlocked) { + return res.status(403).json({ + error: "Failed to unlock user data with SSO credentials", + }); + } } apiLogger.info("Exporting user data as SQLite", { @@ -622,11 +650,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => { throw new Error("User data not unlocked"); } - const user = await getDb().select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { - throw new Error(`User not found: ${userId}`); - } - const tempDir = process.env.NODE_ENV === "production" ? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports") @@ -805,20 +828,20 @@ app.post("/database/export", authenticateJWT, async (req, res) => { userRecord.id, userRecord.username, "[EXPORTED_USER_NO_PASSWORD]", - userRecord.is_admin ? 1 : 0, - userRecord.is_oidc ? 1 : 0, - userRecord.oidc_identifier || null, - userRecord.client_id || null, - userRecord.client_secret || null, - userRecord.issuer_url || null, - userRecord.authorization_url || null, - userRecord.token_url || null, - userRecord.identifier_path || null, - userRecord.name_path || null, + userRecord.isAdmin ? 1 : 0, + userRecord.isOidc ? 1 : 0, + userRecord.oidcIdentifier || null, + userRecord.clientId || null, + userRecord.clientSecret || null, + userRecord.issuerUrl || null, + userRecord.authorizationUrl || null, + userRecord.tokenUrl || null, + userRecord.identifierPath || null, + userRecord.namePath || null, userRecord.scopes || null, - userRecord.totp_secret || null, - userRecord.totp_enabled ? 1 : 0, - userRecord.totp_backup_codes || null, + userRecord.totpSecret || null, + userRecord.totpEnabled ? 1 : 0, + userRecord.totpBackupCodes || null, ); const sshHosts = await getDb() @@ -851,31 +874,31 @@ app.post("/database/export", authenticateJWT, async (req, res) => { decrypted.forceKeyboardInteractive || null, decrypted.password || null, decrypted.key || null, - decrypted.key_password || null, + decrypted.keyPassword || null, decrypted.keyType || null, decrypted.sudoPassword || null, decrypted.autostartPassword || null, decrypted.autostartKey || null, decrypted.autostartKeyPassword || null, decrypted.credentialId || null, - Boolean(decrypted.overrideCredentialUsername) ? 1 : 0, - Boolean(decrypted.enableTerminal) ? 1 : 0, - Boolean(decrypted.enableTunnel) ? 1 : 0, + decrypted.overrideCredentialUsername ? 1 : 0, + decrypted.enableTerminal ? 1 : 0, + decrypted.enableTunnel ? 1 : 0, decrypted.tunnelConnections || null, decrypted.jumpHosts || null, - Boolean(decrypted.enableFileManager) ? 1 : 0, - Boolean(decrypted.enableDocker) ? 1 : 0, - Boolean(decrypted.showTerminalInSidebar) ? 1 : 0, - Boolean(decrypted.showFileManagerInSidebar) ? 1 : 0, - Boolean(decrypted.showTunnelInSidebar) ? 1 : 0, - Boolean(decrypted.showDockerInSidebar) ? 1 : 0, - Boolean(decrypted.showServerStatsInSidebar) ? 1 : 0, + decrypted.enableFileManager ? 1 : 0, + decrypted.enableDocker ? 1 : 0, + decrypted.showTerminalInSidebar ? 1 : 0, + decrypted.showFileManagerInSidebar ? 1 : 0, + decrypted.showTunnelInSidebar ? 1 : 0, + decrypted.showDockerInSidebar ? 1 : 0, + decrypted.showServerStatsInSidebar ? 1 : 0, decrypted.defaultPath || null, decrypted.statsConfig || null, decrypted.terminalConfig || null, decrypted.quickActions || null, decrypted.notes || null, - Boolean(decrypted.useSocks5) ? 1 : 0, + decrypted.useSocks5 ? 1 : 0, decrypted.socks5Host || null, decrypted.socks5Port || null, decrypted.socks5Username || null, @@ -913,9 +936,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => { decrypted.username, decrypted.password || null, decrypted.key || null, - decrypted.private_key || null, - decrypted.public_key || null, - decrypted.key_password || null, + decrypted.privateKey || null, + decrypted.publicKey || null, + decrypted.keyPassword || null, decrypted.keyType || null, decrypted.detectedKeyType || null, decrypted.usageCount || 0, @@ -1135,7 +1158,7 @@ app.post( return res.status(404).json({ error: "User not found" }); } - const isOidcUser = !!userRecords[0].is_oidc; + const isOidcUser = !!userRecords[0].isOidc; if (!isOidcUser) { if (!password) { @@ -1506,7 +1529,7 @@ app.post( .select() .from(users) .where(eq(users.id, userId)); - if (targetUser.length > 0 && targetUser[0].is_admin) { + if (targetUser.length > 0 && targetUser[0].isAdmin) { try { const importedSettings = importDb .prepare("SELECT * FROM settings") @@ -1744,7 +1767,33 @@ app.use("/terminal", terminalRoutes); app.use("/network-topology", networkTopologyRoutes); app.use("/rbac", rbacRoutes); +const frontendDistPaths = [ + path.join(__dirname, "../../../dist"), + path.join(__dirname, "../../dist"), + path.join(process.cwd(), "dist"), +]; + +const frontendDist = frontendDistPaths.find((p) => + fs.existsSync(path.join(p, "index.html")), +); + +if (frontendDist) { + databaseLogger.info(`Serving frontend from: ${frontendDist}`, { + operation: "static_files", + }); + app.use(express.static(frontendDist)); + + app.use((req, res, next) => { + if (req.method === "GET" && req.accepts("html")) { + res.sendFile(path.join(frontendDist, "index.html")); + } else { + next(); + } + }); +} + app.use( + // eslint-disable-next-line @typescript-eslint/no-unused-vars ( err: unknown, req: express.Request, @@ -1819,13 +1868,17 @@ app.get( if (status.hasUnencryptedDb) { try { unencryptedSize = fs.statSync(dbPath).size; - } catch (error) {} + } catch { + // expected - file may not exist + } } if (status.hasEncryptedDb) { try { encryptedSize = fs.statSync(encryptedDbPath).size; - } catch (error) {} + } catch { + // expected - file may not exist + } } res.json({ diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 11afd2f4..b2ae22d5 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -37,10 +37,11 @@ async function initializeDatabaseAsync(): Promise { memoryDatabase = new Database(decryptedBuffer); try { - const sessionCount = memoryDatabase + memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; - } catch (countError) { + } catch { + // expected - sessions table may not exist yet } } else { const migration = new DatabaseMigration(dataDir); @@ -182,6 +183,18 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS trusted_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -750,6 +763,33 @@ const migrateSchema = () => { } } + try { + sqlite + .prepare("SELECT id FROM trusted_devices LIMIT 1") + .get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS trusted_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create trusted_devices table", { + operation: "schema_migration", + error: createError, + }); + } + } + try { sqlite .prepare("SELECT id FROM network_topology LIMIT 1") @@ -1033,23 +1073,15 @@ const migrateSchema = () => { try { const validSystemRoles = ['admin', 'user']; const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member']; - let deletedCount = 0; - const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?"); for (const roleName of unwantedRoleNames) { - const result = deleteByName.run(roleName); - if (result.changes > 0) { - deletedCount += result.changes; - } + deleteByName.run(roleName); } const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1"); for (const role of existingRoles) { if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) { - const result = deleteOldSystemRole.run(role.name); - if (result.changes > 0) { - deletedCount += result.changes; - } + deleteOldSystemRole.run(role.name); } } } catch (cleanupError) { @@ -1107,7 +1139,7 @@ const migrateSchema = () => { for (const admin of adminUsers) { try { insertUserRole.run(admin.id, adminRole.id); - } catch (error) { + } catch { // Ignore duplicate errors } } @@ -1122,7 +1154,7 @@ const migrateSchema = () => { for (const user of normalUsers) { try { insertUserRole.run(user.id, userRole.id); - } catch (error) { + } catch { // Ignore duplicate errors } } @@ -1156,10 +1188,11 @@ async function saveMemoryDatabaseToFile() { } try { - const sessionCount = memoryDatabase + memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; - } catch (countError) { + } catch { + // expected - sessions table may not exist yet } if (enableFileEncryption) { @@ -1170,6 +1203,8 @@ async function saveMemoryDatabaseToFile() { } else { fs.writeFileSync(dbPath, buffer); } + + DatabaseSaveTrigger.markClean(); } catch (error) { databaseLogger.error("Failed to save in-memory database", error, { operation: "memory_db_save_failed", @@ -1185,7 +1220,11 @@ async function handlePostInitFileEncryption() { if (memoryDatabase) { await saveMemoryDatabaseToFile(); - setInterval(saveMemoryDatabaseToFile, 15 * 1000); + setInterval(() => { + if (DatabaseSaveTrigger.isDirty) { + saveMemoryDatabaseToFile(); + } + }, 5 * 60 * 1000); DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile); } @@ -1254,15 +1293,18 @@ async function cleanupDatabase() { try { fs.unlinkSync(path.join(tempDir, file)); } catch { + // expected - file cleanup best effort } } try { fs.rmdirSync(tempDir); } catch { + // expected - dir cleanup best effort } } } catch { + // expected - temp dir cleanup best effort } } @@ -1271,6 +1313,7 @@ process.on("exit", () => { try { sqlite.close(); } catch { + // expected - database may already be closed } } }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 474f0a31..3a17c077 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -4,25 +4,25 @@ import { sql } from "drizzle-orm"; export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull(), - password_hash: text("password_hash").notNull(), - is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false), + passwordHash: text("password_hash").notNull(), + isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), - is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false), - oidc_identifier: text("oidc_identifier"), - client_id: text("client_id"), - client_secret: text("client_secret"), - issuer_url: text("issuer_url"), - authorization_url: text("authorization_url"), - token_url: text("token_url"), - identifier_path: text("identifier_path"), - name_path: text("name_path"), + isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false), + oidcIdentifier: text("oidc_identifier"), + clientId: text("client_id"), + clientSecret: text("client_secret"), + issuerUrl: text("issuer_url"), + authorizationUrl: text("authorization_url"), + tokenUrl: text("token_url"), + identifierPath: text("identifier_path"), + namePath: text("name_path"), scopes: text().default("openid email profile"), - totp_secret: text("totp_secret"), - totp_enabled: integer("totp_enabled", { mode: "boolean" }) + totpSecret: text("totp_secret"), + totpEnabled: integer("totp_enabled", { mode: "boolean" }) .notNull() .default(false), - totp_backup_codes: text("totp_backup_codes"), + totpBackupCodes: text("totp_backup_codes"), }); export const settings = sqliteTable("settings", { @@ -47,6 +47,23 @@ export const sessions = sqliteTable("sessions", { .default(sql`CURRENT_TIMESTAMP`), }); +export const trustedDevices = sqliteTable("trusted_devices", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceFingerprint: text("device_fingerprint").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsedAt: text("last_used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const sshData = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") @@ -64,7 +81,7 @@ export const sshData = sqliteTable("ssh_data", { password: text("password"), key: text("key", { length: 8192 }), - key_password: text("key_password"), + keyPassword: text("key_password"), keyType: text("key_type"), sudoPassword: text("sudo_password"), @@ -202,9 +219,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", { username: text("username"), password: text("password"), key: text("key", { length: 16384 }), - private_key: text("private_key", { length: 16384 }), - public_key: text("public_key", { length: 4096 }), - key_password: text("key_password"), + privateKey: text("private_key", { length: 16384 }), + publicKey: text("public_key", { length: 4096 }), + keyPassword: text("key_password"), keyType: text("key_type"), detectedKeyType: text("detected_key_type"), diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index dacd6854..86a3cc92 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -225,9 +225,9 @@ router.post( username: username?.trim() || null, password: plainPassword, key: plainKey, - private_key: keyInfo?.privateKey || plainKey, - public_key: keyInfo?.publicKey || null, - key_password: plainKeyPassword, + privateKey: keyInfo?.privateKey || plainKey, + publicKey: keyInfo?.publicKey || null, + keyPassword: plainKeyPassword, keyType: keyType || null, detectedKeyType: keyInfo?.keyType || null, usageCount: 0, @@ -434,14 +434,14 @@ router.get( if (credential.key) { output.key = credential.key; } - if (credential.private_key) { - output.privateKey = credential.private_key; + if (credential.privateKey) { + output.privateKey = credential.privateKey; } - if (credential.public_key) { - output.publicKey = credential.public_key; + if (credential.publicKey) { + output.publicKey = credential.publicKey; } - if (credential.key_password) { - output.keyPassword = credential.key_password; + if (credential.keyPassword) { + output.keyPassword = credential.keyPassword; } res.json(output); @@ -564,13 +564,13 @@ router.put( error: `Invalid SSH key: ${keyInfo.error}`, }); } - updateFields.private_key = keyInfo.privateKey; - updateFields.public_key = keyInfo.publicKey; + updateFields.privateKey = keyInfo.privateKey; + updateFields.publicKey = keyInfo.publicKey; updateFields.detectedKeyType = keyInfo.keyType; } } if (updateData.keyPassword !== undefined) { - updateFields.key_password = updateData.keyPassword || null; + updateFields.keyPassword = updateData.keyPassword || null; } if (Object.keys(updateFields).length === 0) { @@ -606,15 +606,15 @@ router.put( userId, ); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.updateSharedCredentialsForOriginal( parseInt(id), userId, ); - const credential = updated[0]; authLogger.success("SSH credential updated", { operation: "credential_update_success", userId, @@ -706,7 +706,7 @@ router.delete( credentialId: null, password: null, key: null, - key_password: null, + keyPassword: null, authType: "password", }) .where( @@ -737,8 +737,9 @@ router.delete( } } - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); @@ -751,7 +752,6 @@ router.delete( ), ); - const credential = credentialToDelete[0]; authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, @@ -841,10 +841,10 @@ router.post( .set({ credentialId: parseInt(credentialId), username: (credential.username as string) || "", - authType: (credential.auth_type || credential.authType) as string, + authType: credential.authType as string, password: null, key: null, - key_password: null, + keyPassword: null, keyType: null, updatedAt: new Date().toISOString(), }) @@ -954,15 +954,15 @@ function formatCredentialOutput( ? credential.tags.split(",").filter(Boolean) : [] : [], - authType: credential.authType || credential.auth_type, + authType: credential.authType, username: credential.username || null, - publicKey: credential.public_key || credential.publicKey, - keyType: credential.key_type || credential.keyType, - detectedKeyType: credential.detected_key_type || credential.detectedKeyType, - usageCount: credential.usage_count || credential.usageCount || 0, - lastUsed: credential.last_used || credential.lastUsed, - createdAt: credential.created_at || credential.createdAt, - updatedAt: credential.updated_at || credential.updatedAt, + publicKey: credential.publicKey, + keyType: credential.keyType, + detectedKeyType: credential.detectedKeyType, + usageCount: credential.usageCount || 0, + lastUsed: credential.lastUsed, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, }; } @@ -1508,7 +1508,7 @@ async function deploySSHKeyToHost( hostConfig: Record, credData: CredentialBackend, ): Promise<{ success: boolean; message?: string; error?: string }> { - const publicKey = credData.public_key as string; + const publicKey = credData.publicKey as string; return new Promise((resolve) => { const conn = new Client(); @@ -1934,7 +1934,7 @@ router.post( }); } - const publicKey = credData.public_key; + const publicKey = credData.publicKey; if (!publicKey) { return res.status(400).json({ success: false, @@ -1990,15 +1990,14 @@ router.post( if (hostCredential && hostCredential.length > 0) { const cred = hostCredential[0]; - hostConfig.authType = cred.auth_type || cred.authType; + hostConfig.authType = cred.authType; hostConfig.username = cred.username; - if ((cred.auth_type || cred.authType) === "password") { + if (cred.authType === "password") { hostConfig.password = cred.password; - } else if ((cred.auth_type || cred.authType) === "key") { - hostConfig.privateKey = - cred.private_key || cred.privateKey || cred.key; - hostConfig.keyPassword = cred.key_password || cred.keyPassword; + } else if (cred.authType === "key") { + hostConfig.privateKey = cred.privateKey || cred.key; + hostConfig.keyPassword = cred.keyPassword; } } else { return res.status(400).json({ diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 03879bab..98ba1ba0 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -7,11 +7,10 @@ import { users, roles, userRoles, - auditLogs, sharedCredentials, } from "../db/schema.js"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; -import type { Request, Response } from "express"; +import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; @@ -202,8 +201,9 @@ router.post( .delete(sharedCredentials) .where(eq(sharedCredentials.hostAccessId, existing[0].id)); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( @@ -244,8 +244,9 @@ router.post( expiresAt, }); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { @@ -857,10 +858,7 @@ router.delete( permissionManager.invalidateUserPermissionCache(userId); } - const deletedHostAccess = await db - .delete(hostAccess) - .where(eq(hostAccess.roleId, roleId)) - .returning({ id: hostAccess.id }); + await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId)); await db.delete(roles).where(eq(roles.id, roleId)); @@ -983,8 +981,9 @@ router.post( .innerJoin(sshData, eq(hostAccess.hostId, sshData.id)) .where(eq(hostAccess.roleId, roleId)); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); for (const { host_access, ssh_data } of hostsSharedWithRole) { diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index a7cf8ed4..6870f6a5 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -655,7 +655,7 @@ router.post( let password = host.password; let privateKey = host.key; - let passphrase = host.key_password; + let passphrase = host.keyPassword; let authType = host.authType; if (host.credentialId) { @@ -675,12 +675,12 @@ router.post( if (credResult.length > 0) { const cred = credResult[0]; - authType = (cred.auth_type || cred.authType || authType) as string; + authType = (cred.authType || authType) as string; password = (cred.password || undefined) as string | undefined; - privateKey = (cred.private_key || cred.key || undefined) as + privateKey = (cred.privateKey || cred.key || undefined) as | string | undefined; - passphrase = (cred.key_password || undefined) as string | undefined; + passphrase = (cred.keyPassword || undefined) as string | undefined; } } @@ -731,7 +731,7 @@ router.post( reject(err); }); - const config: any = { + const config: Record = { host: host.ip, port: host.port, username: host.username, diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 5a8a0d1b..d164d33d 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -251,7 +251,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => { username: host.username, password: host.autostartPassword || host.password, key: host.autostartKey || host.key, - keyPassword: host.autostartKeyPassword || host.key_password, + keyPassword: host.autostartKeyPassword || host.keyPassword, autostartPassword: host.autostartPassword, autostartKey: host.autostartKey, autostartKeyPassword: host.autostartKeyPassword, @@ -426,8 +426,16 @@ router.post( showDockerInSidebar: showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0, defaultPath: defaultPath || null, - statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, - terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, + statsConfig: statsConfig + ? typeof statsConfig === "string" + ? statsConfig + : JSON.stringify(statsConfig) + : null, + terminalConfig: terminalConfig + ? typeof terminalConfig === "string" + ? terminalConfig + : JSON.stringify(terminalConfig) + : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", notes: notes || null, sudoPassword: sudoPassword || null, @@ -444,7 +452,7 @@ router.post( if (effectiveAuthType === "password") { sshDataObj.password = password || null; sshDataObj.key = null; - sshDataObj.key_password = null; + sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { if (key && typeof key === "string") { @@ -481,13 +489,13 @@ router.post( } sshDataObj.key = key || null; - sshDataObj.key_password = keyPassword || null; + sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; } else { sshDataObj.password = null; sshDataObj.key = null; - sshDataObj.key_password = null; + sshDataObj.keyPassword = null; sshDataObj.keyType = null; } @@ -682,16 +690,10 @@ router.post( const cred = credentials[0]; resolvedPassword = cred.password as string | undefined; - resolvedKey = (cred.private_key || cred.privateKey || cred.key) as - | string - | undefined; - resolvedKeyPassword = (cred.key_password || cred.keyPassword) as - | string - | undefined; - resolvedKeyType = (cred.key_type || cred.keyType) as string | undefined; - resolvedAuthType = (cred.auth_type || cred.authType) as - | string - | undefined; + resolvedKey = cred.privateKey as string | undefined; + resolvedKeyPassword = cred.keyPassword as string | undefined; + resolvedKeyType = cred.keyType as string | undefined; + resolvedAuthType = cred.authType as string | undefined; if (!overrideCredentialUsername) { resolvedUsername = cred.username as string; @@ -909,8 +911,16 @@ router.put( showDockerInSidebar: showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0, defaultPath: defaultPath || null, - statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, - terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, + statsConfig: statsConfig + ? typeof statsConfig === "string" + ? statsConfig + : JSON.stringify(statsConfig) + : null, + terminalConfig: terminalConfig + ? typeof terminalConfig === "string" + ? terminalConfig + : JSON.stringify(terminalConfig) + : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", notes: notes || null, sudoPassword: sudoPassword || null, @@ -929,7 +939,7 @@ router.put( sshDataObj.password = password; } sshDataObj.key = null; - sshDataObj.key_password = null; + sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { if (key && typeof key === "string") { @@ -969,7 +979,7 @@ router.put( sshDataObj.key = key; } if (keyPassword !== undefined) { - sshDataObj.key_password = keyPassword || null; + sshDataObj.keyPassword = keyPassword || null; } if (keyType) { sshDataObj.keyType = keyType; @@ -978,7 +988,7 @@ router.put( } else { sshDataObj.password = null; sshDataObj.key = null; - sshDataObj.key_password = null; + sshDataObj.keyPassword = null; sshDataObj.keyType = null; } @@ -1055,10 +1065,9 @@ router.put( hostRecord[0].credentialId !== null && sshDataObj.credentialId === null ) { - const revokedShares = await db + await db .delete(hostAccess) - .where(eq(hostAccess.hostId, Number(hostId))) - .returning({ id: hostAccess.id, userId: hostAccess.userId }); + .where(eq(hostAccess.hostId, Number(hostId))); } } @@ -1189,7 +1198,7 @@ router.get( authType: sshData.authType, password: sshData.password, key: sshData.key, - keyPassword: sshData.key_password, + keyPassword: sshData.keyPassword, keyType: sshData.keyType, enableTerminal: sshData.enableTerminal, enableTunnel: sshData.enableTunnel, @@ -1264,7 +1273,7 @@ router.get( const ownHosts = rawData.filter((row) => row.userId === userId); const sharedHosts = rawData.filter((row) => row.userId !== userId); - let decryptedOwnHosts: any[] = []; + let decryptedOwnHosts: Record[] = []; try { decryptedOwnHosts = await SimpleDBOps.select( Promise.resolve(ownHosts), @@ -1447,24 +1456,53 @@ router.get( authType: resolvedHost.authType, password: resolvedHost.password || null, key: resolvedHost.key || null, - keyPassword: resolvedHost.key_password || null, + keyPassword: resolvedHost.keyPassword || null, keyType: resolvedHost.keyType || null, + credentialId: resolvedHost.credentialId || null, + overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, folder: resolvedHost.folder, tags: typeof resolvedHost.tags === "string" ? resolvedHost.tags.split(",").filter(Boolean) : resolvedHost.tags || [], pin: !!resolvedHost.pin, + notes: resolvedHost.notes || null, enableTerminal: !!resolvedHost.enableTerminal, enableTunnel: !!resolvedHost.enableTunnel, enableFileManager: !!resolvedHost.enableFileManager, + enableDocker: !!resolvedHost.enableDocker, + showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar, + showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar, + showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar, + showDockerInSidebar: !!resolvedHost.showDockerInSidebar, + showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar, defaultPath: resolvedHost.defaultPath, + sudoPassword: resolvedHost.sudoPassword || null, tunnelConnections: resolvedHost.tunnelConnections ? JSON.parse(resolvedHost.tunnelConnections as string) : [], + jumpHosts: resolvedHost.jumpHosts + ? JSON.parse(resolvedHost.jumpHosts as string) + : null, + quickActions: resolvedHost.quickActions + ? JSON.parse(resolvedHost.quickActions as string) + : null, + statsConfig: resolvedHost.statsConfig + ? JSON.parse(resolvedHost.statsConfig as string) + : null, + terminalConfig: resolvedHost.terminalConfig + ? JSON.parse(resolvedHost.terminalConfig as string) + : null, + forceKeyboardInteractive: + resolvedHost.forceKeyboardInteractive === "true", + useSocks5: !!resolvedHost.useSocks5, + socks5Host: resolvedHost.socks5Host || null, + socks5Port: resolvedHost.socks5Port || null, + socks5Username: resolvedHost.socks5Username || null, + socks5Password: resolvedHost.socks5Password || null, socks5ProxyChain: resolvedHost.socks5ProxyChain ? JSON.parse(resolvedHost.socks5ProxyChain as string) - : [], + : null, }; sshLogger.success("Host exported with decrypted credentials", { @@ -1583,7 +1621,6 @@ router.delete( .delete(sshData) .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); - const host = hostToDelete[0]; databaseLogger.success("SSH host deleted", { operation: "host_delete_success", userId, @@ -2417,8 +2454,8 @@ async function resolveHostCredentials( ...host, password: credential.password, key: credential.key, - keyPassword: credential.key_password || credential.keyPassword, - keyType: credential.key_type || credential.keyType, + keyPassword: credential.keyPassword, + keyType: credential.keyType, }; if (!host.overrideCredentialUsername) { @@ -2429,14 +2466,7 @@ async function resolveHostCredentials( } } - const result = { ...host }; - if (host.key_password !== undefined) { - if (result.keyPassword === undefined) { - result.keyPassword = host.key_password; - } - delete result.key_password; - } - return result; + return { ...host }; } catch (error) { sshLogger.warn( `Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -2846,12 +2876,142 @@ router.delete( * 400: * description: Invalid request body. */ + +/** + * @swagger + * /ssh/bulk-update: + * patch: + * summary: Bulk update partial fields on multiple SSH hosts + * tags: [SSH] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostIds: + * type: array + * items: + * type: number + * updates: + * type: object + * responses: + * 200: + * description: Bulk update completed. + * 400: + * description: Invalid request body. + */ +router.patch( + "/bulk-update", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostIds, updates } = req.body; + + if (!Array.isArray(hostIds) || hostIds.length === 0) { + return res + .status(400) + .json({ error: "hostIds array is required and must not be empty" }); + } + + if (hostIds.length > 1000) { + return res + .status(400) + .json({ error: "Maximum 1000 hosts allowed per bulk update" }); + } + + if ( + !updates || + typeof updates !== "object" || + Object.keys(updates).length === 0 + ) { + return res.status(400).json({ + error: "updates object is required and must contain at least one field", + }); + } + + try { + const ownedHosts = await db + .select({ id: sshData.id, statsConfig: sshData.statsConfig }) + .from(sshData) + .where(and(inArray(sshData.id, hostIds), eq(sshData.userId, userId))); + + const ownedIds = ownedHosts.map((h) => h.id); + const unauthorizedIds = hostIds.filter( + (id: number) => !ownedIds.includes(id), + ); + + if (ownedIds.length === 0) { + return res.status(404).json({ error: "No matching hosts found" }); + } + + const errors: string[] = []; + if (unauthorizedIds.length > 0) { + errors.push(`${unauthorizedIds.length} host(s) not found or not owned`); + } + + const simpleUpdates: Record = {}; + if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin; + if (typeof updates.folder === "string") + simpleUpdates.folder = updates.folder || null; + if (typeof updates.enableTerminal === "boolean") + simpleUpdates.enableTerminal = updates.enableTerminal; + if (typeof updates.enableTunnel === "boolean") + simpleUpdates.enableTunnel = updates.enableTunnel; + if (typeof updates.enableFileManager === "boolean") + simpleUpdates.enableFileManager = updates.enableFileManager; + if (typeof updates.enableDocker === "boolean") + simpleUpdates.enableDocker = updates.enableDocker; + + if (Object.keys(simpleUpdates).length > 0) { + await db + .update(sshData) + .set(simpleUpdates) + .where( + and(inArray(sshData.id, ownedIds), eq(sshData.userId, userId)), + ); + } + + if (updates.statsConfig && typeof updates.statsConfig === "object") { + for (const host of ownedHosts) { + try { + const existing = host.statsConfig + ? JSON.parse(host.statsConfig as string) + : {}; + const merged = { ...existing, ...updates.statsConfig }; + await db + .update(sshData) + .set({ statsConfig: JSON.stringify(merged) }) + .where(and(eq(sshData.id, host.id), eq(sshData.userId, userId))); + } catch (e) { + errors.push(`Failed to update statsConfig for host ${host.id}`); + } + } + } + + DatabaseSaveTrigger.triggerSave("bulk_update"); + + return res.json({ + updated: ownedIds.length, + failed: unauthorizedIds.length, + errors, + }); + } catch (error) { + sshLogger.error("Failed to bulk update hosts:", error); + return res.status(500).json({ error: "Failed to bulk update hosts" }); + } + }, +); + router.post( "/bulk-import", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { hosts } = req.body; + const { hosts, overwrite } = req.body; if (!Array.isArray(hosts) || hosts.length === 0) { return res @@ -2867,10 +3027,30 @@ router.post( const results = { success: 0, + updated: 0, + skipped: 0, failed: 0, errors: [] as string[], }; + let existingHostMap: Map | undefined; + if (overwrite) { + try { + const allHosts = await SimpleDBOps.select>( + db.select().from(sshData).where(eq(sshData.userId, userId)), + "ssh_data", + userId, + ); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + } + for (let i = 0; i < hosts.length; i++) { const hostData = hosts[i]; @@ -2940,9 +3120,7 @@ router.post( hostData.authType === "credential" ? hostData.credentialId : null, key: hostData.authType === "key" ? hostData.key : null, keyPassword: - hostData.authType === "key" - ? hostData.keyPassword || hostData.key_password || null - : null, + hostData.authType === "key" ? hostData.keyPassword || null : null, keyType: hostData.authType === "key" ? hostData.keyType || "auto" : null, pin: hostData.pin || false, @@ -2950,7 +3128,13 @@ router.post( enableTunnel: hostData.enableTunnel !== false, enableFileManager: hostData.enableFileManager !== false, enableDocker: hostData.enableDocker || false, + showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0, + showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0, + showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0, + showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0, + showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0, defaultPath: hostData.defaultPath || "/", + sudoPassword: hostData.sudoPassword || null, tunnelConnections: hostData.tunnelConnections ? JSON.stringify(hostData.tunnelConnections) : "[]", @@ -2981,12 +3165,26 @@ router.post( overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; - await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId); - results.success++; + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + + if (existing) { + await SimpleDBOps.update( + sshData, + "ssh_data", + eq(sshData.id, existing.id), + sshDataObj, + userId, + ); + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId); + results.success++; + } } catch (error) { results.failed++; results.errors.push( @@ -2996,8 +3194,10 @@ router.post( } res.json({ - message: `Import completed: ${results.success} successful, ${results.failed} failed`, + message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, + updated: results.updated, + skipped: results.skipped, failed: results.failed, errors: results.errors, }); @@ -3130,7 +3330,7 @@ router.post( ...tunnel, endpointPassword: decryptedEndpoint.password || null, endpointKey: decryptedEndpoint.key || null, - endpointKeyPassword: decryptedEndpoint.key_password || null, + endpointKeyPassword: decryptedEndpoint.keyPassword || null, endpointAuthType: endpointHost.authType, }; } @@ -3153,7 +3353,7 @@ router.post( .set({ autostartPassword: decryptedConfig.password || null, autostartKey: decryptedConfig.key || null, - autostartKeyPassword: decryptedConfig.key_password || null, + autostartKeyPassword: decryptedConfig.keyPassword || null, tunnelConnections: updatedTunnelConnections, }) .where(eq(sshData.id, sshConfigId)); @@ -3466,9 +3666,17 @@ function rewriteOPKSSHHtml( ): string { const basePath = `/ssh/${routePrefix}/${requestId}`; - html = html.replace(/action=["']?\//g, `action="${basePath}/`); - html = html.replace(/href=["']?\//g, `href="${basePath}/`); - html = html.replace(/src=["']?\//g, `src="${basePath}/`); + const attrPatterns = ["action", "href", "src"]; + for (const attr of attrPatterns) { + html = html.replace( + new RegExp(`${attr}="(/[^"]*)`, "g"), + `${attr}="${basePath}$1`, + ); + html = html.replace( + new RegExp(`${attr}='(/[^']*)`, "g"), + `${attr}='${basePath}$1`, + ); + } html = html.replace( /href=["']?http:\/\/localhost:\d+\/([^"'\s]*)/g, @@ -3518,11 +3726,37 @@ function rewriteOPKSSHHtml( const baseTag = ``; if (html.includes("]*>/i, baseTag); - } else { + } else if (html.includes("")) { + sshLogger.info("Inserting base tag into head", { + operation: "opkssh_html_rewrite_base_tag_insert", + requestId, + basePath, + }); html = html.replace(//i, `${baseTag}`); + } else { + sshLogger.warn("No tag found, wrapping HTML", { + operation: "opkssh_html_rewrite_no_head", + requestId, + htmlLength: html.length, + htmlPreview: html.substring(0, 200), + }); + html = `${baseTag}${html}`; } + sshLogger.info("HTML rewrite complete", { + operation: "opkssh_html_rewrite_complete", + requestId, + routePrefix, + hasBaseTag: html.includes(" @@ -3682,6 +3934,14 @@ router.use( const targetUrl = `http://localhost:${session.localPort}${targetPath}`; + sshLogger.info("Proxying to OPKSSH chooser", { + operation: "opkssh_chooser_proxy_request_to_opkssh", + requestId, + targetUrl, + localPort: session.localPort, + targetPath, + }); + const response = await axios({ method: req.method, url: targetUrl, @@ -3696,6 +3956,15 @@ router.use( responseType: "arraybuffer", }); + sshLogger.info("OPKSSH chooser response received", { + operation: "opkssh_chooser_proxy_response", + requestId, + statusCode: response.status, + contentType: response.headers["content-type"], + contentLength: response.headers["content-length"], + hasLocation: !!response.headers.location, + }); + Object.entries(response.headers).forEach(([key, value]) => { if (key.toLowerCase() === "transfer-encoding") { return; @@ -3821,57 +4090,103 @@ router.use( */ router.get("/opkssh-callback", async (req: Request, res: Response) => { try { - const { getActiveSessionsAll } = await import("../../ssh/opkssh-auth.js"); + sshLogger.info("OAuth callback received", { + operation: "opkssh_static_callback_received", + url: req.url, + originalUrl: req.originalUrl, + query: req.query, + headers: { + host: req.headers.host, + "x-forwarded-proto": req.headers["x-forwarded-proto"], + "x-forwarded-host": req.headers["x-forwarded-host"], + "x-forwarded-port": req.headers["x-forwarded-port"], + }, + }); - const allSessions = getActiveSessionsAll(); - const session = allSessions[allSessions.length - 1]; + const { getUserIdFromRequest, getActiveSessionsForUser } = + await import("../../ssh/opkssh-auth.js"); - if (!session) { + const userId = await getUserIdFromRequest({ + cookies: req.cookies, + headers: req.headers as Record, + }); + + sshLogger.info("User ID resolved", { + operation: "opkssh_callback_user_lookup", + userId: userId || "null", + hasCookies: !!req.cookies?.jwt, + cookieKeys: Object.keys(req.cookies || {}), + }); + + if (!userId) { + sshLogger.error("No userId from callback request", { + operation: "opkssh_callback_unauthorized", + cookies: Object.keys(req.cookies || {}), + headers: Object.keys(req.headers), + }); + res.status(401).send("Unauthorized - no valid session"); + return; + } + + const userSessions = getActiveSessionsForUser(userId); + + sshLogger.info("Active sessions for user", { + operation: "opkssh_callback_session_lookup", + userId, + sessionCount: userSessions.length, + sessions: userSessions.map((s) => ({ + requestId: s.requestId, + status: s.status, + hasCallbackPort: !!s.callbackPort, + callbackPort: s.callbackPort, + hasLocalPort: !!s.localPort, + localPort: s.localPort, + })), + }); + + if (userSessions.length === 0) { + sshLogger.error("No active sessions for callback", { + operation: "opkssh_callback_no_sessions", + userId, + }); res.status(404).send("No active authentication session found"); return; } + const session = userSessions[userSessions.length - 1]; + if (!session.callbackPort) { + sshLogger.error("Session callback port not ready", { + operation: "opkssh_callback_port_not_ready", + userId, + requestId: session.requestId, + sessionStatus: session.status, + hasLocalPort: !!session.localPort, + }); res.status(503).send("OPKSSH callback listener not ready yet"); return; } - const axios = (await import("axios")).default; const queryString = req.url.includes("?") ? req.url.substring(req.url.indexOf("?")) : ""; - const targetUrl = `http://localhost:${session.callbackPort}/login-callback${queryString}`; + const redirectUrl = `/ssh/opkssh-callback/${session.requestId}/login-callback${queryString}`; - sshLogger.info("Proxying OAuth callback to OPKSSH", { - operation: "opkssh_static_callback", + sshLogger.info("Redirecting OAuth callback to dynamic route", { + operation: "opkssh_static_callback_redirect", + userId, + requestId: session.requestId, callbackPort: session.callbackPort, - targetUrl, + queryParams: Object.keys(req.query), + redirectUrl, }); - const response = await axios({ - method: req.method, - url: targetUrl, - headers: { - ...req.headers, - host: `localhost:${session.callbackPort}`, - }, - data: req.body, - timeout: 10000, - validateStatus: () => true, - maxRedirects: 0, - responseType: "arraybuffer", - }); - - Object.entries(response.headers).forEach(([key, value]) => { - if (key.toLowerCase() !== "transfer-encoding") { - res.setHeader(key, value as string); - } - }); - - res.status(response.status).send(response.data); + res.redirect(302, redirectUrl); } catch (error) { sshLogger.error("Error handling OPKSSH static callback", error, { operation: "opkssh_static_callback_error", + url: req.url, + originalUrl: req.originalUrl, }); res.status(500).send("Authentication callback failed"); } @@ -4139,4 +4454,80 @@ router.use( }, ); +/** + * @openapi + * /db/proxy/test: + * post: + * summary: Test proxy connectivity + * description: Tests connectivity through a proxy configuration to a target host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * singleProxy: + * type: object + * properties: + * host: + * type: string + * port: + * type: number + * type: + * type: string + * username: + * type: string + * password: + * type: string + * proxyChain: + * type: array + * items: + * type: object + * testTarget: + * type: object + * properties: + * host: + * type: string + * port: + * type: number + * responses: + * 200: + * description: Test result + * 500: + * description: Proxy connection failed + */ +router.post( + "/db/proxy/test", + authenticateJWT, + requireDataAccess, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { singleProxy, proxyChain, testTarget } = req.body; + + const { testProxyConnectivity } = + await import("../../utils/proxy-helper.js"); + + const result = await testProxyConnectivity({ + singleProxy, + proxyChain, + testTarget, + }); + + res.json(result); + } catch (error) { + sshLogger.error("Proxy connectivity test failed", error, { + operation: "proxy_test", + userId: req.userId, + }); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }, +); + export default router; diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts index be48359e..6b35107e 100644 --- a/src/backend/database/routes/terminal.ts +++ b/src/backend/database/routes/terminal.ts @@ -285,4 +285,118 @@ router.delete( }, ); +/** + * @openapi + * /terminal/session_settings: + * get: + * summary: Get session persistence settings + * description: Returns the session timeout and persistence enabled flag. + * tags: + * - Terminal + * responses: + * 200: + * description: Session settings. + * 500: + * description: Failed to fetch settings. + */ +router.get( + "/session_settings", + authenticateJWT, + async (_req: Request, res: Response) => { + try { + const timeoutRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", + ) + .get() as { value: string } | undefined; + const enabledRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'", + ) + .get() as { value: string } | undefined; + + res.json({ + timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30, + enabled: enabledRow ? enabledRow.value === "true" : true, + }); + } catch (err) { + authLogger.error("Failed to fetch session settings", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch settings", + }); + } + }, +); + +/** + * @openapi + * /terminal/session_settings: + * post: + * summary: Update session persistence settings + * description: Saves session timeout and persistence enabled flag. + * tags: + * - Terminal + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * timeoutMinutes: + * type: integer + * enabled: + * type: boolean + * responses: + * 200: + * description: Settings saved successfully. + * 400: + * description: Invalid parameters. + * 500: + * description: Failed to save settings. + */ +router.post( + "/session_settings", + authenticateJWT, + async (req: Request, res: Response) => { + const { timeoutMinutes, enabled } = req.body; + + if ( + timeoutMinutes !== undefined && + (typeof timeoutMinutes !== "number" || + timeoutMinutes < 1 || + timeoutMinutes > 1440) + ) { + return res + .status(400) + .json({ error: "timeoutMinutes must be between 1 and 1440" }); + } + + try { + if (timeoutMinutes !== undefined) { + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)", + ) + .run(String(timeoutMinutes)); + } + + if (enabled !== undefined) { + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)", + ) + .run(String(enabled)); + } + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to save session settings", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to save settings", + }); + } + }, +); + export default router; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 10612b17..63b10212 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -24,26 +24,28 @@ import { sharedCredentials, auditLogs, sessionRecordings, + networkTopology, + dashboardPreferences, + opksshTokens, } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import type { Request, Response } from "express"; -import { authLogger, databaseLogger } from "../../utils/logger.js"; +import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; -import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { + parseUserAgent, + generateDeviceFingerprint, +} from "../../utils/user-agent-parser.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; const authManager = AuthManager.getInstance(); -/** - * Get OIDC configuration from environment variables. - * Returns null if required env vars are not set. - */ function getOIDCConfigFromEnv(): { client_id: string; client_secret: string; @@ -54,6 +56,7 @@ function getOIDCConfigFromEnv(): { identifier_path: string; name_path: string; scopes: string; + allowed_users: string; } | null { const client_id = process.env.OIDC_CLIENT_ID; const client_secret = process.env.OIDC_CLIENT_SECRET; @@ -81,9 +84,40 @@ function getOIDCConfigFromEnv(): { identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub", name_path: process.env.OIDC_NAME_PATH || "name", scopes: process.env.OIDC_SCOPES || "openid email profile", + allowed_users: process.env.OIDC_ALLOWED_USERS || "", }; } +function isOIDCUserAllowed( + allowedUsers: string, + identifier: string, + email?: string, +): boolean { + if (!allowedUsers || !allowedUsers.trim()) return true; + const patterns = allowedUsers + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + if (patterns.length === 0) return true; + + const values = [ + identifier, + ...(email && email !== identifier ? [email] : []), + ]; + for (const pattern of patterns) { + if (pattern === "*") return true; + for (const value of values) { + if (!value) continue; + if (pattern.toLowerCase().startsWith("@")) { + if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true; + } else { + if (value.toLowerCase() === pattern.toLowerCase()) return true; + } + } + } + return false; +} + async function verifyOIDCToken( idToken: string, issuerUrl: string, @@ -137,6 +171,7 @@ async function verifyOIDCToken( ); } } else { + // expected - non-ok response, try next URL } } catch { continue; @@ -232,6 +267,12 @@ async function deleteUserAndRelatedData(userId: string): Promise { await db.delete(sshData).where(eq(sshData.userId, userId)); await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); + await db.delete(networkTopology).where(eq(networkTopology.userId, userId)); + await db + .delete(dashboardPreferences) + .where(eq(dashboardPreferences.userId, userId)); + await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId)); + db.$client .prepare("DELETE FROM settings WHERE key LIKE ?") .run(`user_%_${userId}`); @@ -345,20 +386,20 @@ router.post("/create", async (req, res) => { await db.insert(users).values({ id, username, - password_hash, - is_admin: isFirstUser, - is_oidc: false, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", + passwordHash: password_hash, + isAdmin: isFirstUser, + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", scopes: "openid email profile", - totp_secret: null, - totp_enabled: false, - totp_backup_codes: null, + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, }); try { @@ -453,7 +494,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -467,6 +508,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { identifier_path, name_path, scopes, + allowed_users, } = req.body; const isDisableRequest = @@ -524,6 +566,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { identifier_path, name_path, scopes: scopes || "openid email profile", + allowed_users: allowed_users || "", }; let encryptedConfig; @@ -603,7 +646,7 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -707,7 +750,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { } else { config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; } - } catch (decryptError) { + } catch { authLogger.warn("Failed to decrypt OIDC config for admin", { operation: "oidc_config_decrypt_failed", userId, @@ -721,7 +764,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { "base64", ).toString("utf8"); config.client_secret = decoded; - } catch (decodeError) { + } catch { authLogger.warn("Failed to decode OIDC config for admin", { operation: "oidc_config_decode_failed", userId, @@ -755,6 +798,17 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { */ router.get("/oidc/authorize", async (req, res) => { try { + authLogger.info("OIDC authorize request headers", { + protocol: req.protocol, + host: req.get("Host"), + origin: req.get("Origin"), + referer: req.get("Referer"), + "x-forwarded-proto": req.get("X-Forwarded-Proto"), + "x-forwarded-host": req.get("X-Forwarded-Host"), + "x-forwarded-port": req.get("X-Forwarded-Port"), + secure: req.secure, + }); + const envConfig = getOIDCConfigFromEnv(); let config; @@ -772,28 +826,46 @@ router.get("/oidc/authorize", async (req, res) => { const state = nanoid(); const nonce = nanoid(); - let origin = - req.get("Origin") || - req.get("Referer")?.replace(/\/[^/]*$/, "") || - "http://localhost:5173"; + const protocol = + process.env.OIDC_FORCE_HTTPS === "true" + ? "https" + : req.get("X-Forwarded-Proto") || req.protocol; - if (origin.includes("localhost")) { - origin = "http://localhost:30001"; + const host = req.get("Host"); + const origin = `${protocol}://${host}`; + const backendCallbackUri = `${origin}/users/oidc/callback`; + + const referer = req.get("Referer"); + let frontendOrigin; + if (referer) { + const refererUrl = new URL(referer); + frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; + } else { + frontendOrigin = origin; } - const redirectUri = `${origin}/users/oidc/callback`; - db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run(`oidc_state_${state}`, nonce); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_redirect_${state}`, redirectUri); + .run(`oidc_backend_callback_${state}`, backendCallbackUri); + + db.$client + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run(`oidc_frontend_origin_${state}`, frontendOrigin); + + authLogger.info("OIDC authorization initiated", { + operation: "oidc_authorize", + backendCallbackUri, + frontendOrigin, + referer, + }); const authUrl = new URL(config.authorization_url); authUrl.searchParams.set("client_id", config.client_id); - authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("redirect_uri", backendCallbackUri); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", config.scopes); authUrl.searchParams.set("state", state); @@ -831,15 +903,23 @@ router.get("/oidc/callback", async (req, res) => { return res.status(400).json({ error: "Code and state are required" }); } - const storedRedirectRow = db.$client + const storedBackendCallbackRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_redirect_${state}`); - if (!storedRedirectRow) { + .get(`oidc_backend_callback_${state}`); + const storedFrontendOriginRow = db.$client + .prepare("SELECT value FROM settings WHERE key = ?") + .get(`oidc_frontend_origin_${state}`); + + if (!storedBackendCallbackRow || !storedFrontendOriginRow) { return res .status(400) - .json({ error: "Invalid state parameter - redirect URI not found" }); + .json({ error: "Invalid state parameter - redirect URIs not found" }); } - const redirectUri = (storedRedirectRow as Record) + + const backendCallbackUri = ( + storedBackendCallbackRow as Record + ).value as string; + const frontendOrigin = (storedFrontendOriginRow as Record) .value as string; try { @@ -850,13 +930,6 @@ router.get("/oidc/callback", async (req, res) => { return res.status(400).json({ error: "Invalid state parameter" }); } - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_state_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_redirect_${state}`); - const envConfig = getOIDCConfigFromEnv(); let config; @@ -874,6 +947,12 @@ router.get("/oidc/callback", async (req, res) => { ); } + authLogger.info("OIDC token exchange attempt", { + operation: "oidc_token_exchange", + backendCallbackUri, + frontendOrigin, + }); + const tokenResponse = await fetch(config.token_url, { method: "POST", headers: { @@ -885,15 +964,20 @@ router.get("/oidc/callback", async (req, res) => { client_id: config.client_id, client_secret: config.client_secret, code: code, - redirect_uri: redirectUri, + redirect_uri: backendCallbackUri, }), }); if (!tokenResponse.ok) { - authLogger.error( - "OIDC token exchange failed", - await tokenResponse.text(), - ); + const errorText = await tokenResponse.text(); + authLogger.error("OIDC token exchange failed", { + operation: "oidc_token_exchange_failed", + status: tokenResponse.status, + statusText: tokenResponse.statusText, + backendCallbackUri, + frontendOrigin, + errorResponse: errorText, + }); return res .status(400) .json({ error: "Failed to exchange authorization code" }); @@ -901,6 +985,16 @@ router.get("/oidc/callback", async (req, res) => { const tokenData = (await tokenResponse.json()) as Record; + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`oidc_state_${state}`); + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`oidc_backend_callback_${state}`); + db.$client + .prepare("DELETE FROM settings WHERE key = ?") + .run(`oidc_frontend_origin_${state}`); + let userInfo: Record = null; const userInfoUrls: string[] = []; @@ -1032,7 +1126,7 @@ router.get("/oidc/callback", async (req, res) => { let user = await db .select() .from(users) - .where(eq(users.oidc_identifier, identifier)); + .where(eq(users.oidcIdentifier, identifier)); let isFirstUser = false; if (!user || user.length === 0) { @@ -1041,6 +1135,20 @@ router.get("/oidc/callback", async (req, res) => { .get(); isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; + if (!isFirstUser && config.allowed_users) { + const email = userInfo.email as string | undefined; + if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { + authLogger.warn("OIDC user not in allowed list", { + operation: "oidc_user_not_allowed", + identifier, + email, + }); + const redirectUrl = new URL(frontendOrigin); + redirectUrl.searchParams.set("error", "user_not_allowed"); + return res.redirect(redirectUrl.toString()); + } + } + if (!isFirstUser) { try { const regRow = db.$client @@ -1058,14 +1166,7 @@ router.get("/oidc/callback", async (req, res) => { }, ); - let frontendUrl = (redirectUri as string).replace( - "/users/oidc/callback", - "", - ); - if (frontendUrl.includes("localhost")) { - frontendUrl = "http://localhost:5173"; - } - const redirectUrl = new URL(frontendUrl); + const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "registration_disabled"); return res.redirect(redirectUrl.toString()); @@ -1082,17 +1183,17 @@ router.get("/oidc/callback", async (req, res) => { await db.insert(users).values({ id, username: name, - password_hash: "", - is_admin: isFirstUser, - is_oidc: true, - oidc_identifier: identifier, - client_id: String(config.client_id), - client_secret: String(config.client_secret), - issuer_url: String(config.issuer_url), - authorization_url: String(config.authorization_url), - token_url: String(config.token_url), - identifier_path: String(config.identifier_path), - name_path: String(config.name_path), + passwordHash: "", + isAdmin: isFirstUser, + isOidc: true, + oidcIdentifier: identifier, + clientId: String(config.client_id), + clientSecret: String(config.client_secret), + issuerUrl: String(config.issuer_url), + authorizationUrl: String(config.authorization_url), + tokenUrl: String(config.token_url), + identifierPath: String(config.identifier_path), + namePath: String(config.name_path), scopes: String(config.scopes), }); @@ -1135,7 +1236,7 @@ router.get("/oidc/callback", async (req, res) => { const sessionDurationMs = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + : 2 * 60 * 60 * 1000; await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { await db.delete(users).where(eq(users.id, id)); @@ -1164,8 +1265,23 @@ router.get("/oidc/callback", async (req, res) => { user = await db.select().from(users).where(eq(users.id, id)); } else { + if (config.allowed_users) { + const email = userInfo.email as string | undefined; + if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { + authLogger.warn("OIDC user not in allowed list (existing user)", { + operation: "oidc_user_not_allowed_existing", + identifier, + email, + userId: user[0].id, + }); + const redirectUrl = new URL(frontendOrigin); + redirectUrl.searchParams.set("error", "user_not_allowed"); + return res.redirect(redirectUrl.toString()); + } + } + const isDualAuth = - user[0].password_hash && user[0].password_hash.trim() !== ""; + user[0].passwordHash && user[0].passwordHash.trim() !== ""; if (!isDualAuth) { await db @@ -1188,6 +1304,15 @@ router.get("/oidc/callback", async (req, res) => { }); } + try { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id); + } catch { + // expected - re-encryption may fail if no pending credentials + } + const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, @@ -1199,22 +1324,13 @@ router.get("/oidc/callback", async (req, res) => { username: userRecord.username, }); - let frontendUrl = (redirectUri as string).replace( - "/users/oidc/callback", - "", - ); - - if (frontendUrl.includes("localhost")) { - frontendUrl = "http://localhost:5173"; - } - - const redirectUrl = new URL(frontendUrl); + const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + : 2 * 60 * 60 * 1000; res.clearCookie("jwt", authManager.getClearCookieOptions(req)); @@ -1224,16 +1340,7 @@ router.get("/oidc/callback", async (req, res) => { } catch (err) { authLogger.error("OIDC callback failed", err); - let frontendUrl = (redirectUri as string).replace( - "/users/oidc/callback", - "", - ); - - if (frontendUrl.includes("localhost")) { - frontendUrl = "http://localhost:5173"; - } - - const redirectUrl = new URL(frontendUrl); + const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "OIDC authentication failed"); res.redirect(redirectUrl.toString()); @@ -1274,7 +1381,7 @@ router.get("/oidc/callback", async (req, res) => { * description: Login failed. */ router.post("/login", async (req, res) => { - const { username, password } = req.body; + const { username, password, rememberMe } = req.body; const clientIp = req.ip || req.socket.remoteAddress || "unknown"; authLogger.info("User login request received", { operation: "user_login_request", @@ -1344,8 +1451,8 @@ router.post("/login", async (req, res) => { const userRecord = user[0]; if ( - userRecord.is_oidc && - (!userRecord.password_hash || userRecord.password_hash.trim() === "") + userRecord.isOidc && + (!userRecord.passwordHash || userRecord.passwordHash.trim() === "") ) { authLogger.warn("OIDC-only user attempted traditional login", { operation: "user_login", @@ -1357,7 +1464,7 @@ router.post("/login", async (req, res) => { .json({ error: "This user uses external authentication" }); } - const isMatch = await bcrypt.compare(password, userRecord.password_hash); + const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: incorrect password`, { @@ -1382,12 +1489,14 @@ router.post("/login", async (req, res) => { if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } - } catch (error) {} + } catch { + // expected - KEK salt registration may fail for existing users + } const deviceInfo = parseUserAgent(req); let dataUnlocked = false; - if (userRecord.is_oidc) { + if (userRecord.isOidc) { dataUnlocked = await authManager.authenticateOIDCUser( userRecord.id, deviceInfo.type, @@ -1417,19 +1526,35 @@ router.post("/login", async (req, res) => { }); } - if (userRecord.totp_enabled) { - const tempToken = await authManager.generateJWTToken(userRecord.id, { - pendingTOTP: true, - expiresIn: "10m", - }); - return res.json({ - success: true, - requires_totp: true, - temp_token: tempToken, - }); + if (userRecord.totpEnabled) { + const deviceFingerprint = generateDeviceFingerprint(deviceInfo); + + const isTrusted = rememberMe + ? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint) + : false; + + if (isTrusted) { + authLogger.info("TOTP bypassed for trusted device", { + operation: "totp_bypass", + userId: userRecord.id, + deviceFingerprint, + }); + } else { + const tempToken = await authManager.generateJWTToken(userRecord.id, { + pendingTOTP: true, + expiresIn: "10m", + }); + return res.json({ + success: true, + requires_totp: true, + temp_token: tempToken, + rememberMe: !!rememberMe, + }); + } } const token = await authManager.generateJWTToken(userRecord.id, { + rememberMe: !!rememberMe, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); @@ -1446,7 +1571,7 @@ router.post("/login", async (req, res) => { const response: Record = { success: true, - is_admin: !!userRecord.is_admin, + is_admin: !!userRecord.isAdmin, username: userRecord.username, }; @@ -1458,10 +1583,7 @@ router.post("/login", async (req, res) => { response.token = token; } - const maxAge = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) @@ -1500,7 +1622,9 @@ router.post("/logout", authenticateJWT, async (req, res) => { try { const payload = await authManager.verifyJWTToken(token); sessionId = payload?.sessionId; - } catch (error) {} + } catch { + // expected - token verification may fail during logout + } } await authManager.logoutUser(userId, sessionId); @@ -1551,17 +1675,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { } const hasPassword = - user[0].password_hash && user[0].password_hash.trim() !== ""; - const hasOidc = user[0].is_oidc && user[0].oidc_identifier; + user[0].passwordHash && user[0].passwordHash.trim() !== ""; + const hasOidc = user[0].isOidc && user[0].oidcIdentifier; const isDualAuth = hasPassword && hasOidc; res.json({ userId: user[0].id, username: user[0].username, - is_admin: !!user[0].is_admin, - is_oidc: !!user[0].is_oidc, + is_admin: !!user[0].isAdmin, + is_oidc: !!user[0].isOidc, is_dual_auth: isDualAuth, - totp_enabled: !!user[0].totp_enabled, + totp_enabled: !!user[0].totpEnabled, }); } catch (err) { authLogger.error("Failed to get username", err); @@ -1619,7 +1743,7 @@ router.get("/count", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user[0] || !user[0].is_admin) { + if (!user[0] || !user[0].isAdmin) { return res.status(403).json({ error: "Admin access required" }); } @@ -1717,7 +1841,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; @@ -1793,7 +1917,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; @@ -1871,7 +1995,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; @@ -1939,14 +2063,14 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { const userRecord = user[0]; - if (userRecord.is_oidc) { + if (userRecord.isOidc) { return res.status(403).json({ error: "Cannot delete external authentication accounts through this endpoint", }); } - const isMatch = await bcrypt.compare(password, userRecord.password_hash); + const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { authLogger.warn( `Incorrect password provided for account deletion: ${userRecord.username}`, @@ -1954,7 +2078,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { return res.status(401).json({ error: "Incorrect password" }); } - if (userRecord.is_admin) { + if (userRecord.isAdmin) { const adminCount = db.$client .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") .get(); @@ -2040,7 +2164,7 @@ router.post("/initiate-reset", async (req, res) => { return res.status(404).json({ error: "User not found" }); } - if (user[0].is_oidc) { + if (user[0].isOidc) { return res.status(403).json({ error: "Password reset not available for external authentication users", }); @@ -2308,7 +2432,7 @@ router.post("/complete-reset", async (req, res) => { await db .update(users) - .set({ password_hash }) + .set({ passwordHash: password_hash }) .where(eq(users.id, userId)); authManager.logoutUser(userId); authLogger.success( @@ -2336,7 +2460,7 @@ router.post("/complete-reset", async (req, res) => { } else { await db .update(users) - .set({ password_hash }) + .set({ passwordHash: password_hash }) .where(eq(users.username, username)); try { @@ -2370,9 +2494,9 @@ router.post("/complete-reset", async (req, res) => { await db .update(users) .set({ - totp_enabled: false, - totp_secret: null, - totp_backup_codes: null, + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, }) .where(eq(users.id, userId)); @@ -2468,7 +2592,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => { return res.status(404).json({ error: "User not found" }); } - const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash); + const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash); if (!isMatch) { authLogger.warn("Password change failed - old password incorrect", { operation: "password_change_failed", @@ -2491,7 +2615,10 @@ router.post("/change-password", authenticateJWT, async (req, res) => { const saltRounds = parseInt(process.env.SALT || "10", 10); const password_hash = await bcrypt.hash(newPassword, saltRounds); - await db.update(users).set({ password_hash }).where(eq(users.id, userId)); + await db + .update(users) + .set({ passwordHash: password_hash }) + .where(eq(users.id, userId)); authManager.logoutUser(userId); authLogger.success("Password changed successfully", { @@ -2522,7 +2649,7 @@ router.get("/list", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -2530,9 +2657,9 @@ router.get("/list", authenticateJWT, async (req, res) => { .select({ id: users.id, username: users.username, - is_admin: users.is_admin, - is_oidc: users.is_oidc, - password_hash: users.password_hash, + isAdmin: users.isAdmin, + isOidc: users.isOidc, + passwordHash: users.passwordHash, }) .from(users); @@ -2582,7 +2709,7 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -2594,13 +2721,13 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].is_admin) { + if (targetUser[0].isAdmin) { return res.status(400).json({ error: "User is already an admin" }); } await db .update(users) - .set({ is_admin: true }) + .set({ isAdmin: true }) .where(eq(users.username, username)); try { @@ -2665,7 +2792,7 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -2683,13 +2810,13 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { return res.status(404).json({ error: "User not found" }); } - if (!targetUser[0].is_admin) { + if (!targetUser[0].isAdmin) { return res.status(400).json({ error: "User is not an admin" }); } await db .update(users) - .set({ is_admin: false }) + .set({ isAdmin: false }) .where(eq(users.username, username)); try { @@ -2744,7 +2871,7 @@ router.post("/totp/setup", authenticateJWT, async (req, res) => { const userRecord = user[0]; - if (userRecord.totp_enabled) { + if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } @@ -2755,7 +2882,7 @@ router.post("/totp/setup", authenticateJWT, async (req, res) => { await db .update(users) - .set({ totp_secret: secret.base32 }) + .set({ totpSecret: secret.base32 }) .where(eq(users.id, userId)); const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); @@ -2815,16 +2942,16 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => { const userRecord = user[0]; - if (userRecord.totp_enabled) { + if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } - if (!userRecord.totp_secret) { + if (!userRecord.totpSecret) { return res.status(400).json({ error: "TOTP setup not initiated" }); } const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret, + secret: userRecord.totpSecret, encoding: "base32", token: totp_code, window: 2, @@ -2841,8 +2968,8 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => { await db .update(users) .set({ - totp_enabled: true, - totp_backup_codes: JSON.stringify(backupCodes), + totpEnabled: true, + totpBackupCodes: JSON.stringify(backupCodes), }) .where(eq(users.id, userId)); authLogger.info("Two-factor authentication enabled", { @@ -2907,18 +3034,18 @@ router.post("/totp/disable", authenticateJWT, async (req, res) => { const userRecord = user[0]; - if (!userRecord.totp_enabled) { + if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); } - if (password && !userRecord.is_oidc) { - const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (password && !userRecord.isOidc) { + const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { return res.status(401).json({ error: "Incorrect password" }); } } else if (totp_code) { const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret!, + secret: userRecord.totpSecret!, encoding: "base32", token: totp_code, window: 2, @@ -2934,9 +3061,9 @@ router.post("/totp/disable", authenticateJWT, async (req, res) => { await db .update(users) .set({ - totp_enabled: false, - totp_secret: null, - totp_backup_codes: null, + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, }) .where(eq(users.id, userId)); authLogger.info("Two-factor authentication disabled", { @@ -2998,18 +3125,18 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { const userRecord = user[0]; - if (!userRecord.totp_enabled) { + if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); } - if (password && !userRecord.is_oidc) { - const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (password && !userRecord.isOidc) { + const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { return res.status(401).json({ error: "Incorrect password" }); } } else if (totp_code) { const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret!, + secret: userRecord.totpSecret!, encoding: "base32", token: totp_code, window: 2, @@ -3028,7 +3155,7 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { await db .update(users) - .set({ totp_backup_codes: JSON.stringify(backupCodes) }) + .set({ totpBackupCodes: JSON.stringify(backupCodes) }) .where(eq(users.id, userId)); res.json({ backup_codes: backupCodes }); @@ -3070,7 +3197,7 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { * description: TOTP verification failed. */ router.post("/totp/verify-login", async (req, res) => { - const { temp_token, totp_code } = req.body; + const { temp_token, totp_code, rememberMe } = req.body; if (!temp_token || !totp_code) { return res.status(400).json({ error: "Token and TOTP code are required" }); @@ -3108,7 +3235,7 @@ router.post("/totp/verify-login", async (req, res) => { loginRateLimiter.recordFailedTOTPAttempt(userRecord.id); - if (!userRecord.totp_enabled || !userRecord.totp_secret) { + if (!userRecord.totpEnabled || !userRecord.totpSecret) { return res.status(400).json({ error: "TOTP not enabled for this user" }); } @@ -3121,7 +3248,7 @@ router.post("/totp/verify-login", async (req, res) => { } const totpSecret = LazyFieldEncryption.safeGetFieldValue( - userRecord.totp_secret, + userRecord.totpSecret, userDataKey, userRecord.id, "totp_secret", @@ -3131,9 +3258,9 @@ router.post("/totp/verify-login", async (req, res) => { await db .update(users) .set({ - totp_enabled: false, - totp_secret: null, - totp_backup_codes: null, + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, }) .where(eq(users.id, userRecord.id)); @@ -3153,8 +3280,8 @@ router.post("/totp/verify-login", async (req, res) => { if (!verified) { let backupCodes = []; try { - backupCodes = userRecord.totp_backup_codes - ? JSON.parse(userRecord.totp_backup_codes) + backupCodes = userRecord.totpBackupCodes + ? JSON.parse(userRecord.totpBackupCodes) : []; } catch { backupCodes = []; @@ -3185,14 +3312,31 @@ router.post("/totp/verify-login", async (req, res) => { backupCodes.splice(backupIndex, 1); await db .update(users) - .set({ totp_backup_codes: JSON.stringify(backupCodes) }) + .set({ totpBackupCodes: JSON.stringify(backupCodes) }) .where(eq(users.id, userRecord.id)); } loginRateLimiter.resetTOTPAttempts(userRecord.id); const deviceInfo = parseUserAgent(req); + + if (rememberMe) { + const deviceFingerprint = generateDeviceFingerprint(deviceInfo); + await authManager.addTrustedDevice( + userRecord.id, + deviceFingerprint, + deviceInfo.type, + deviceInfo.deviceInfo, + ); + authLogger.info("Device automatically trusted via Remember Me", { + operation: "totp_auto_trust", + userId: userRecord.id, + deviceType: deviceInfo.type, + }); + } + const token = await authManager.generateJWTToken(userRecord.id, { + rememberMe: !!rememberMe, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); @@ -3210,21 +3354,18 @@ router.post("/totp/verify-login", async (req, res) => { const response: Record = { success: true, - is_admin: !!userRecord.is_admin, + is_admin: !!userRecord.isAdmin, username: userRecord.username, userId: userRecord.id, - is_oidc: !!userRecord.is_oidc, - totp_enabled: !!userRecord.totp_enabled, + is_oidc: !!userRecord.isOidc, + totp_enabled: !!userRecord.totpEnabled, }; if (isElectron) { response.token = token; } - const maxAge = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) @@ -3274,7 +3415,7 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } @@ -3290,7 +3431,7 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].is_admin) { + if (targetUser[0].isAdmin) { const adminCount = db.$client .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") .get(); @@ -3447,7 +3588,7 @@ router.get("/sessions", authenticateJWT, async (req, res) => { const userRecord = user[0]; let sessionList; - if (userRecord.is_admin) { + if (userRecord.isAdmin) { sessionList = await authManager.getAllSessions(); const enrichedSessions = await Promise.all( @@ -3533,7 +3674,7 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => { const session = sessionRecords[0]; - if (!userRecord.is_admin && session.userId !== userId) { + if (!userRecord.isAdmin && session.userId !== userId) { return res .status(403) .json({ error: "Not authorized to revoke this session" }); @@ -3600,7 +3741,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { const userRecord = user[0]; let revokeUserId = userId; - if (targetUserId && userRecord.is_admin) { + if (targetUserId && userRecord.isAdmin) { revokeUserId = targetUserId; } else if (targetUserId && targetUserId !== userId) { return res.status(403).json({ @@ -3687,7 +3828,7 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { .select() .from(users) .where(eq(users.id, adminUserId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Admin access required" }); } @@ -3701,7 +3842,7 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { const oidcUser = oidcUserRecords[0]; - if (!oidcUser.is_oidc) { + if (!oidcUser.isOidc) { return res.status(400).json({ error: "Source user is not an OIDC user", }); @@ -3717,13 +3858,13 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { const targetUser = targetUserRecords[0]; - if (targetUser.is_oidc || !targetUser.password_hash) { + if (targetUser.isOidc || !targetUser.passwordHash) { return res.status(400).json({ error: "Target user must be a password-based account", }); } - if (targetUser.client_id && targetUser.oidc_identifier) { + if (targetUser.clientId && targetUser.oidcIdentifier) { return res.status(400).json({ error: "Target user already has OIDC authentication configured", }); @@ -3741,15 +3882,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { await db .update(users) .set({ - is_oidc: true, - oidc_identifier: oidcUser.oidc_identifier, - client_id: oidcUser.client_id, - client_secret: oidcUser.client_secret, - issuer_url: oidcUser.issuer_url, - authorization_url: oidcUser.authorization_url, - token_url: oidcUser.token_url, - identifier_path: oidcUser.identifier_path, - name_path: oidcUser.name_path, + isOidc: true, + oidcIdentifier: oidcUser.oidcIdentifier, + clientId: oidcUser.clientId, + clientSecret: oidcUser.clientSecret, + issuerUrl: oidcUser.issuerUrl, + authorizationUrl: oidcUser.authorizationUrl, + tokenUrl: oidcUser.tokenUrl, + identifierPath: oidcUser.identifierPath, + namePath: oidcUser.namePath, scopes: oidcUser.scopes || "openid email profile", }) .where(eq(users.id, targetUser.id)); @@ -3768,15 +3909,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { await db .update(users) .set({ - is_oidc: false, - oidc_identifier: null, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", + isOidc: false, + oidcIdentifier: null, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", scopes: "openid email profile", }) .where(eq(users.id, targetUser.id)); @@ -3882,7 +4023,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { .from(users) .where(eq(users.id, adminUserId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { authLogger.warn("Non-admin attempted to unlink OIDC from password", { operation: "unlink_oidc_unauthorized", adminUserId, @@ -3906,13 +4047,13 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { const targetUser = targetUserRecords[0]; - if (!targetUser.is_oidc) { + if (!targetUser.isOidc) { return res.status(400).json({ error: "User does not have OIDC authentication enabled", }); } - if (!targetUser.password_hash || targetUser.password_hash === "") { + if (!targetUser.passwordHash || targetUser.passwordHash === "") { return res.status(400).json({ error: "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", @@ -3929,15 +4070,15 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { await db .update(users) .set({ - is_oidc: false, - oidc_identifier: null, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", + isOidc: false, + oidcIdentifier: null, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", scopes: "openid email profile", }) .where(eq(users.id, targetUser.id)); diff --git a/src/backend/scripts/enable-ssl.sh b/src/backend/scripts/enable-ssl.sh index 9fd76e1e..f5be625a 100644 --- a/src/backend/scripts/enable-ssl.sh +++ b/src/backend/scripts/enable-ssl.sh @@ -99,5 +99,4 @@ main() { setup_ssl_certificates } -# Run main function -main "$@" \ No newline at end of file +main "$@" diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts index 54d7549c..46a50654 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/ssh/auth-manager.ts @@ -1,12 +1,9 @@ -import { Client } from "ssh2"; import type { WebSocket } from "ws"; import { sshLogger, authLogger } from "../utils/logger.js"; import { getDb } from "../database/db/index.js"; -import { sshCredentials, sshData } from "../database/db/schema.js"; +import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { UserCrypto } from "../utils/user-crypto.js"; - interface ResolvedCredentials { username: string; password?: string; @@ -44,9 +41,6 @@ interface AuthContext { totpAttempts: number; } -const userCrypto = UserCrypto.getInstance(); -const MAX_TOTP_ATTEMPTS = 3; - export class SSHAuthManager { public context: AuthContext; @@ -85,7 +79,7 @@ export class SSHAuthManager { key: cred.privateKey ? Buffer.from(cred.privateKey as string) : undefined, - keyPassword: (cred.passphrase as string) || undefined, + keyPassword: (cred.keyPassword as string) || undefined, authType: (cred.authType as string) || "none", }; } @@ -163,7 +157,7 @@ export class SSHAuthManager { ); if (urlMatch) { - this.context.keyboardInteractiveFinish = (responses: string[]) => { + this.context.keyboardInteractiveFinish = () => { finish([""]); }; @@ -358,7 +352,7 @@ export class SSHAuthManager { stage: string, level: string, message: string, - details?: Record, + details?: Record, ): void { this.context.ws.send( JSON.stringify({ diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts index e45cb559..eee441b4 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/ssh/docker-console.ts @@ -13,7 +13,7 @@ const sshLogger = systemLogger; interface SSHSession { client: SSHClient; - stream: any; + stream: import("ssh2").ClientChannel | null; isConnected: boolean; containerId?: string; shell?: string; @@ -42,7 +42,7 @@ const wss = new WebSocketServer({ } return true; - } catch (error) { + } catch { return false; } }, @@ -92,7 +92,7 @@ async function detectShell( } async function createJumpHostChain( - jumpHosts: any[], + jumpHosts: Array<{ hostId: number }>, userId: string, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { @@ -129,7 +129,12 @@ async function createJumpHostChain( } } - let resolvedCredentials: any = { + let resolvedCredentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + authType?: string; + } = { password: jumpHost.password, sshKey: jumpHost.key, keyPassword: jumpHost.keyPassword, @@ -154,19 +159,18 @@ async function createJumpHostChain( if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { - password: credential.password, - sshKey: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - authType: credential.auth_type || credential.authType, + password: credential.password as string | undefined, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + authType: credential.authType as string | undefined, }; } } const client = new SSHClient(); - const config: any = { - host: jumpHost.ip, + const config: Record = { + host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip, port: jumpHost.port || 22, username: jumpHost.username, tryKeyboard: true, @@ -225,7 +229,7 @@ async function createJumpHostChain( } wss.on("connection", async (ws: WebSocket, req) => { - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; const sessionId = `docker-console-${Date.now()}-${Math.random()}`; sshLogger.info("Docker console WebSocket connected", { operation: "docker_console_connect", @@ -235,6 +239,12 @@ wss.on("connection", async (ws: WebSocket, req) => { let sshSession: SSHSession | null = null; + const wsPingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping(); + } + }, 30000); + ws.on("message", async (data) => { try { const message = JSON.parse(data.toString()); @@ -286,7 +296,12 @@ wss.on("connection", async (ws: WebSocket, req) => { } try { - let resolvedCredentials: any = { + let resolvedCredentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + authType?: string; + } = { password: hostConfig.password, sshKey: hostConfig.key, keyPassword: hostConfig.keyPassword, @@ -311,22 +326,18 @@ wss.on("connection", async (ws: WebSocket, req) => { if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { - password: credential.password, - sshKey: - credential.private_key || - credential.privateKey || - credential.key, - keyPassword: - credential.key_password || credential.keyPassword, - authType: credential.auth_type || credential.authType, + password: credential.password as string | undefined, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + authType: credential.authType as string | undefined, }; } } const client = new SSHClient(); - const config: any = { - host: hostConfig.ip, + const config: Record = { + host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip, port: hostConfig.port || 22, username: hostConfig.username, tryKeyboard: true, @@ -362,18 +373,20 @@ wss.on("connection", async (ws: WebSocket, req) => { userId, ); if (jumpClient) { - const stream = await new Promise((resolve, reject) => { - jumpClient.forwardOut( - "127.0.0.1", - 0, - hostConfig.ip, - hostConfig.port || 22, - (err, stream) => { - if (err) return reject(err); - resolve(stream); - }, - ); - }); + const stream = await new Promise( + (resolve, reject) => { + jumpClient.forwardOut( + "127.0.0.1", + 0, + hostConfig.ip, + hostConfig.port || 22, + (err, stream) => { + if (err) return reject(err); + resolve(stream); + }, + ); + }, + ); config.sock = stream; } } @@ -496,7 +509,9 @@ wss.on("connection", async (ws: WebSocket, req) => { } }); - stream.stderr.on("data", (data: Buffer) => {}); + stream.stderr.on("data", () => { + // stderr output ignored + }); stream.on("close", () => { if (ws.readyState === WebSocket.OPEN) { @@ -556,7 +571,7 @@ wss.on("connection", async (ws: WebSocket, req) => { case "resize": { if (sshSession && sshSession.stream) { const { cols, rows } = message.data; - sshSession.stream.setWindow(rows, cols); + sshSession.stream.setWindow(rows, cols, rows, cols); } break; } @@ -608,6 +623,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }); ws.on("close", () => { + clearInterval(wsPingInterval); sshLogger.info("Docker console disconnected", { operation: "docker_console_disconnect", sessionId, @@ -641,7 +657,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }); process.on("SIGTERM", () => { - activeSessions.forEach((session, sessionId) => { + activeSessions.forEach((session) => { if (session.stream) { session.stream.end(); } diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index 234d8cc7..b2d8cdf5 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -3,15 +3,17 @@ import cors from "cors"; import cookieParser from "cookie-parser"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; -import type { ClientChannel } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { logger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; -import type { AuthenticatedRequest, SSHHost } from "../../types/index.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../utils/socks5-helper.js"; +import type { SSHHost, ProxyNode } from "../../types/index.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; @@ -21,7 +23,7 @@ function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, - details?: Record, + details?: Record, ): Omit { return { type, @@ -43,7 +45,7 @@ interface SSHSession { interface PendingTOTPSession { client: SSHClient; finish: (responses: string[]) => void; - config: any; + config: Record; createdAt: number; sessionId: string; hostId?: number; @@ -70,7 +72,9 @@ setInterval(() => { if (now - session.createdAt > 180000) { try { session.client.end(); - } catch {} + } catch { + // expected + } delete pendingTOTPSessions[sessionId]; } }); @@ -94,7 +98,9 @@ function cleanupSession(sessionId: string) { try { session.client.end(); - } catch (error) {} + } catch { + // expected + } clearTimeout(session.timeout); delete sshSessions[sessionId]; } @@ -111,10 +117,24 @@ function scheduleSessionCleanup(sessionId: string) { } } +interface JumpHostConfig { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + [key: string]: unknown; +} + async function resolveJumpHost( hostId: number, userId: string, -): Promise { +): Promise { try { const hosts = await SimpleDBOps.select( getDb() @@ -150,17 +170,16 @@ async function resolveJumpHost( const credential = credentials[0]; return { ...host, - password: credential.password, - key: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - keyType: credential.key_type || credential.keyType, - authType: credential.auth_type || credential.authType, - }; + password: credential.password as string | undefined, + key: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authType: credential.authType as string | undefined, + } as JumpHostConfig; } } - return host; + return host as JumpHostConfig; } catch (error) { sshLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", @@ -174,6 +193,7 @@ async function resolveJumpHost( async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, + socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -187,19 +207,33 @@ async function createJumpHostChain( jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), ); + const totalHops = jumpHostConfigs.length; + for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { sshLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, + hopIndex: i, + totalHops, }); clients.forEach((c) => c.end()); return null; } } + let proxySocket: import("net").Socket | null = null; + if (socks5Config?.useSocks5) { + const firstHop = jumpHostConfigs[0]!; + proxySocket = await createSocks5Connection( + firstHop.ip, + firstHop.port || 22, + socks5Config, + ); + } + for (let i = 0; i < jumpHostConfigs.length; i++) { - const jumpHostConfig = jumpHostConfigs[i]; + const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new SSHClient(); clients.push(jumpClient); @@ -225,16 +259,29 @@ async function createJumpHostChain( jumpClient.on("error", (err) => { clearTimeout(timeout); - sshLogger.error(`Jump host ${i + 1} connection failed`, err, { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - }); + sshLogger.error( + `Jump host ${i + 1}/${totalHops} connection failed`, + err, + { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + hopIndex: i, + totalHops, + previousHop: + i > 0 + ? jumpHostConfigs[i - 1]?.ip + : proxySocket + ? "proxy" + : "direct", + usedProxySocket: i === 0 && !!proxySocket, + }, + ); resolve(false); }); - const connectConfig: any = { - host: jumpHostConfig.ip, + const connectConfig: Record = { + host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, @@ -271,6 +318,9 @@ async function createJumpHostChain( jumpClient.connect(connectConfig); }, ); + } else if (proxySocket) { + connectConfig.sock = proxySocket; + jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } @@ -411,6 +461,10 @@ app.use( app.use(cookieParser()); app.use(express.json({ limit: "100mb" })); app.use(express.urlencoded({ limit: "100mb", extended: true })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); @@ -450,7 +504,6 @@ app.post("/docker/ssh/connect", async (req, res) => { userProvidedPassword, userProvidedSshKey, userProvidedKeyPassword, - forceKeyboardInteractive, useSocks5, socks5Host, socks5Port, @@ -458,7 +511,7 @@ app.post("/docker/ssh/connect", async (req, res) => { socks5Password, socks5ProxyChain, } = req.body; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; const connectionLogs: Array> = []; @@ -533,8 +586,9 @@ app.post("/docker/ssh/connect", async (req, res) => { const host = hosts[0] as unknown as SSHHost; if (host.userId !== userId) { - const { PermissionManager } = - await import("../utils/permission-manager.js"); + const { PermissionManager } = await import( + "../utils/permission-manager.js" + ); const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( userId, @@ -605,11 +659,18 @@ app.post("/docker/ssh/connect", async (req, res) => { if (pendingTOTPSessions[sessionId]) { try { pendingTOTPSessions[sessionId].client.end(); - } catch {} + } catch { + // expected + } delete pendingTOTPSessions[sessionId]; } - let resolvedCredentials: any = { + let resolvedCredentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + authType?: string; + } = { password: host.password, sshKey: host.key, keyPassword: host.keyPassword, @@ -632,8 +693,9 @@ app.post("/docker/ssh/connect", async (req, res) => { if (userId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id, @@ -673,11 +735,10 @@ app.post("/docker/ssh/connect", async (req, res) => { if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { - password: credential.password, - sshKey: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - authType: credential.auth_type || credential.authType, + password: credential.password as string | undefined, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + authType: credential.authType as string | undefined, }; } } @@ -685,8 +746,8 @@ app.post("/docker/ssh/connect", async (req, res) => { const client = new SSHClient(); - const config: any = { - host: host.ip, + const config: Record = { + host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, port: host.port || 22, username: host.username, tryKeyboard: true, @@ -706,6 +767,7 @@ app.post("/docker/ssh/connect", async (req, res) => { }; if (resolvedCredentials.authType === "none") { + // no credentials needed } else if (resolvedCredentials.authType === "password") { if (resolvedCredentials.password) { config.password = resolvedCredentials.password; @@ -871,8 +933,6 @@ app.post("/docker/ssh/connect", async (req, res) => { } let responseSent = false; - let keyboardInteractiveResponded = false; - connectionLogs.push( createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), ); @@ -1089,8 +1149,6 @@ app.post("/docker/ssh/connect", async (req, res) => { if (responseSent) return; responseSent = true; - keyboardInteractiveResponded = true; - pendingTOTPSessions[sessionId] = { client, finish, @@ -1158,8 +1216,6 @@ app.post("/docker/ssh/connect", async (req, res) => { return; } - keyboardInteractiveResponded = true; - pendingTOTPSessions[sessionId] = { client, finish, @@ -1244,8 +1300,6 @@ app.post("/docker/ssh/connect", async (req, res) => { return; } - keyboardInteractiveResponded = true; - pendingTOTPSessions[sessionId] = { client, finish, @@ -1283,121 +1337,160 @@ app.post("/docker/ssh/connect", async (req, res) => { }, ); - if ( + const proxyConfig: SOCKS5Config | null = useSocks5 && - (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0)) - ) { - try { - connectionLogs.push( - createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"), - ); - const socks5Socket = await createSocks5Connection( - host.ip, - host.port || 22, - { + (socks5Host || + (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) + ? { useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, - socks5ProxyChain: socks5ProxyChain as any, - }, + socks5ProxyChain: socks5ProxyChain as ProxyNode[], + } + : null; + + const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0; + + if (hasJumpHosts) { + try { + if (proxyConfig) { + connectionLogs.push( + createConnectionLog( + "info", + "proxy", + "Connecting via proxy + jump hosts", + ), + ); + } + connectionLogs.push( + createConnectionLog( + "info", + "jump", + `Connecting via ${host.jumpHosts!.length} jump host(s)`, + ), + ); + const jumpClient = await createJumpHostChain( + host.jumpHosts as Array<{ hostId: number }>, + userId, + proxyConfig, ); - if (socks5Socket) { - config.sock = socks5Socket; - client.connect(config); - return; + if (!jumpClient) { + connectionLogs.push( + createConnectionLog( + "error", + "jump", + "Failed to establish jump host chain", + ), + ); + return res.status(500).json({ + error: "Failed to establish jump host chain", + connectionLogs, + }); } - } catch (socks5Error) { - sshLogger.error("SOCKS5 connection failed", socks5Error, { - operation: "docker_socks5_connect", + + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port || 22, + (err, stream) => { + if (err) { + sshLogger.error("Failed to forward through jump host", err, { + operation: "docker_jump_forward", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "jump", + `Failed to forward through jump host: ${err.message}`, + ), + ); + jumpClient.end(); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: "Failed to forward through jump host: " + err.message, + connectionLogs, + }); + } + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } catch (jumpError) { + sshLogger.error("Jump host connection failed", jumpError, { + operation: "docker_jump_connect", sessionId, hostId, - proxyHost: socks5Host, - proxyPort: socks5Port || 1080, }); connectionLogs.push( createConnectionLog( "error", - "proxy", - `SOCKS5 proxy connection failed: ${socks5Error instanceof Error ? socks5Error.message : "Unknown error"}`, + "jump", + `Jump host connection failed: ${jumpError instanceof Error ? jumpError.message : "Unknown error"}`, ), ); if (!responseSent) { responseSent = true; return res.status(500).json({ error: - "SOCKS5 proxy connection failed: " + - (socks5Error instanceof Error - ? socks5Error.message + "Jump host connection failed: " + + (jumpError instanceof Error + ? jumpError.message : "Unknown error"), connectionLogs, }); } return; } - } else if (host.jumpHosts && host.jumpHosts.length > 0) { + } else if (proxyConfig) { connectionLogs.push( - createConnectionLog( - "info", - "jump", - `Connecting via ${host.jumpHosts.length} jump host(s)`, - ), + createConnectionLog("info", "proxy", "Connecting via proxy"), ); - const jumpClient = await createJumpHostChain( - host.jumpHosts as Array<{ hostId: number }>, - userId, - ); - - if (!jumpClient) { + try { + const proxySocket = await createSocks5Connection( + host.ip, + host.port || 22, + proxyConfig, + ); + if (proxySocket) { + config.sock = proxySocket; + } + client.connect(config); + } catch (proxyError) { + sshLogger.error("Proxy connection failed", proxyError, { + operation: "docker_proxy_connect", + sessionId, + hostId, + }); connectionLogs.push( createConnectionLog( "error", - "jump", - "Failed to establish jump host chain", + "proxy", + `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, ), ); - return res.status(500).json({ - error: "Failed to establish jump host chain", - connectionLogs, - }); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: + "Proxy connection failed: " + + (proxyError instanceof Error + ? proxyError.message + : "Unknown error"), + connectionLogs, + }); + } + return; } - - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port || 22, - (err, stream) => { - if (err) { - sshLogger.error("Failed to forward through jump host", err, { - operation: "docker_jump_forward", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Failed to forward through jump host: ${err.message}`, - ), - ); - jumpClient.end(); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: "Failed to forward through jump host: " + err.message, - connectionLogs, - }); - } - return; - } - - config.sock = stream; - client.connect(config); - }, - ); } else { client.connect(config); } @@ -1489,7 +1582,7 @@ app.post("/docker/ssh/disconnect", async (req, res) => { */ app.post("/docker/ssh/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { sshLogger.error("TOTP verification rejected: no authenticated user", { @@ -1521,7 +1614,9 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch { + // expected + } sshLogger.warn("TOTP session timeout before code submission", { operation: "docker_totp_verify", sessionId, @@ -1544,7 +1639,19 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { }); let responseSent = false; - let responseTimeout: NodeJS.Timeout; + + const responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + sshLogger.warn("TOTP verification timeout", { + operation: "docker_totp_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "TOTP verification timeout" }); + } + }, 60000); session.client.once("ready", () => { if (responseSent) return; @@ -1633,19 +1740,6 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { res.status(401).json({ status: "error", message: "Invalid TOTP code" }); }); - responseTimeout = setTimeout(() => { - if (!responseSent) { - responseSent = true; - delete pendingTOTPSessions[sessionId]; - sshLogger.warn("TOTP verification timeout", { - operation: "docker_totp_verify", - sessionId, - userId, - }); - res.status(408).json({ error: "TOTP verification timeout" }); - } - }, 60000); - session.finish(responses); }); @@ -1679,7 +1773,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { */ app.post("/docker/ssh/connect-warpgate", async (req, res) => { const { sessionId } = req.body; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { sshLogger.error("Warpgate verification rejected: no authenticated user", { @@ -1715,7 +1809,9 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch { + // expected + } sshLogger.warn("Warpgate session timeout before completion", { operation: "docker_warpgate_verify", sessionId, @@ -1728,7 +1824,19 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => { } let responseSent = false; - let responseTimeout: NodeJS.Timeout; + + const responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + sshLogger.warn("Warpgate verification timeout", { + operation: "docker_warpgate_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "Warpgate verification timeout" }); + } + }, 60000); session.client.once("ready", () => { if (responseSent) return; @@ -1819,19 +1927,6 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => { .json({ status: "error", message: "Warpgate authentication failed" }); }); - responseTimeout = setTimeout(() => { - if (!responseSent) { - responseSent = true; - delete pendingTOTPSessions[sessionId]; - sshLogger.warn("Warpgate verification timeout", { - operation: "docker_warpgate_verify", - sessionId, - userId, - }); - res.status(408).json({ error: "Warpgate verification timeout" }); - } - }, 60000); - session.finish([""]); }); @@ -1941,7 +2036,7 @@ app.get("/docker/ssh/status", async (req, res) => { */ app.get("/docker/validate/:sessionId", async (req, res) => { const { sessionId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2020,7 +2115,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => { code: "DOCKER_ERROR", }); } - } catch (installError) { + } catch { session.activeOperations--; return res.json({ available: false, @@ -2073,7 +2168,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => { app.get("/docker/containers/:sessionId", async (req, res) => { const { sessionId } = req.params; const all = req.query.all !== "false"; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2115,7 +2210,7 @@ app.get("/docker/containers/:sessionId", async (req, res) => { .map((line) => { try { return JSON.parse(line); - } catch (e) { + } catch { sshLogger.warn("Failed to parse container line", { operation: "parse_container", line, @@ -2174,7 +2269,7 @@ app.get("/docker/containers/:sessionId", async (req, res) => { */ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2269,7 +2364,7 @@ app.post( "/docker/containers/:sessionId/:containerId/start", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2369,7 +2464,7 @@ app.post( "/docker/containers/:sessionId/:containerId/stop", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2469,7 +2564,7 @@ app.post( "/docker/containers/:sessionId/:containerId/restart", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2569,7 +2664,7 @@ app.post( "/docker/containers/:sessionId/:containerId/pause", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2669,7 +2764,7 @@ app.post( "/docker/containers/:sessionId/:containerId/unpause", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2774,7 +2869,7 @@ app.delete( async (req, res) => { const { sessionId, containerId } = req.params; const force = req.query.force === "true"; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -2902,7 +2997,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { const timestamps = req.query.timestamps === "true"; const since = req.query.since as string; const until = req.query.until as string; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); @@ -3011,7 +3106,7 @@ app.get( "/docker/containers/:sessionId/:containerId/stats", async (req, res) => { const { sessionId, containerId } = req.params; - const userId = (req as any).userId; + const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index af2d93b5..c108c60d 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -6,11 +6,14 @@ import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshCredentials, sshData } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { fileLogger, sshLogger } from "../utils/logger.js"; +import { fileLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; +import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../utils/socks5-helper.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; @@ -18,7 +21,7 @@ function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, - details?: Record, + details?: Record, ): Omit { return { type, @@ -149,14 +152,32 @@ app.use(cookieParser()); app.use(express.json({ limit: "1gb" })); app.use(express.urlencoded({ limit: "1gb", extended: true })); app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); +interface JumpHostConfig { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + [key: string]: unknown; +} + async function resolveJumpHost( hostId: number, userId: string, -): Promise { +): Promise { try { const hosts = await SimpleDBOps.select( getDb() @@ -192,17 +213,16 @@ async function resolveJumpHost( const credential = credentials[0]; return { ...host, - password: credential.password, - key: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - keyType: credential.key_type || credential.keyType, - authType: credential.auth_type || credential.authType, - }; + password: credential.password as string | undefined, + key: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authType: credential.authType as string | undefined, + } as JumpHostConfig; } } - return host; + return host as JumpHostConfig; } catch (error) { fileLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", @@ -216,6 +236,7 @@ async function resolveJumpHost( async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, + socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -225,17 +246,40 @@ async function createJumpHostChain( const clients: SSHClient[] = []; try { + const jumpHostConfigs: Array>> = + []; for (let i = 0; i < jumpHosts.length; i++) { - const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + const config = await resolveJumpHost(jumpHosts[i].hostId, userId); + jumpHostConfigs.push(config); + } - if (!jumpHostConfig) { + const totalHops = jumpHostConfigs.length; + + for (let i = 0; i < jumpHostConfigs.length; i++) { + if (!jumpHostConfigs[i]) { fileLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, + hopIndex: i, + totalHops, }); clients.forEach((c) => c.end()); return null; } + } + + let proxySocket: import("net").Socket | null = null; + if (socks5Config?.useSocks5) { + const firstHop = jumpHostConfigs[0]!; + proxySocket = await createSocks5Connection( + firstHop.ip, + firstHop.port || 22, + socks5Config, + ); + } + + for (let i = 0; i < jumpHostConfigs.length; i++) { + const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new SSHClient(); clients.push(jumpClient); @@ -261,16 +305,29 @@ async function createJumpHostChain( jumpClient.on("error", (err) => { clearTimeout(timeout); - fileLogger.error(`Jump host ${i + 1} connection failed`, err, { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - }); + fileLogger.error( + `Jump host ${i + 1}/${totalHops} connection failed`, + err, + { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + hopIndex: i, + totalHops, + previousHop: + i > 0 + ? jumpHostConfigs[i - 1]?.ip + : proxySocket + ? "proxy" + : "direct", + usedProxySocket: i === 0 && !!proxySocket, + }, + ); resolve(false); }); - const connectConfig: any = { - host: jumpHostConfig.ip, + const connectConfig: Record = { + host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, @@ -307,6 +364,9 @@ async function createJumpHostChain( jumpClient.connect(connectConfig); }, ); + } else if (proxySocket) { + connectConfig.sock = proxySocket; + jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } @@ -337,6 +397,8 @@ interface SSHSession { timeout?: NodeJS.Timeout; activeOperations: number; sudoPassword?: string; + sftp?: import("ssh2").SFTPWrapper; + poolKey?: string; } interface PendingTOTPSession { @@ -398,6 +460,29 @@ function execWithSudo( }); } +function getSessionSftp( + session: SSHSession, +): Promise { + if (session.sftp) { + return Promise.resolve(session.sftp); + } + return new Promise((resolve, reject) => { + session.client.sftp((err, sftp) => { + if (err) { + return reject(err); + } + session.sftp = sftp; + sftp.on("error", () => { + session.sftp = undefined; + }); + sftp.on("close", () => { + session.sftp = undefined; + }); + resolve(sftp); + }); + }); +} + function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { @@ -414,9 +499,19 @@ function cleanupSession(sessionId: string) { return; } + try { + if (session.sftp) { + session.sftp.end(); + session.sftp = undefined; + } + } catch { + // expected + } try { session.client.end(); - } catch (error) {} + } catch { + // expected + } clearTimeout(session.timeout); delete sshSessions[sessionId]; } @@ -626,8 +721,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { keyPassword, authType, credentialId, - userProvidedPassword, - forceKeyboardInteractive, jumpHosts, useSocks5, socks5Host, @@ -692,7 +785,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (pendingTOTPSessions[sessionId]) { try { pendingTOTPSessions[sessionId].client.end(); - } catch {} + } catch { + // expected + } delete pendingTOTPSessions[sessionId]; } @@ -727,10 +822,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const credential = credentials[0]; resolvedCredentials = { password: credential.password, - sshKey: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - authType: credential.auth_type || credential.authType, + sshKey: credential.privateKey, + keyPassword: credential.keyPassword, + authType: credential.authType, }; connectionLogs.push( createConnectionLog( @@ -782,7 +876,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } const config: Record = { - host: ip, + host: ip?.replace(/^\[|\]$/g, "") || ip, port, username, tryKeyboard: true, @@ -1171,7 +1265,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", errorStage, `DNS resolution failed: ${err.message}`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } else if ( @@ -1184,7 +1281,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", errorStage, `TCP connection failed: ${err.message}`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } else if ( @@ -1197,7 +1297,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", errorStage, `SSH handshake failed: ${err.message}`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } else if ( @@ -1210,7 +1313,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", errorStage, `Authentication failed: ${err.message}`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } else if (err.message.includes("verification failed")) { @@ -1220,7 +1326,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", errorStage, `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } else { @@ -1229,7 +1338,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "error", "error", `SSH connection failed: ${err.message}`, - { errorCode: (err as any).code, errorLevel: (err as any).level }, + { + errorCode: (err as unknown as Record).code, + errorLevel: (err as unknown as Record).level, + }, ), ); } @@ -1262,8 +1374,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { cleanupSession(sessionId); }); - let keyboardInteractiveResponded = false; - client.on( "keyboard-interactive", ( @@ -1292,8 +1402,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (responseSent) return; responseSent = true; - keyboardInteractiveResponded = true; - connectionLogs.push( createConnectionLog( "info", @@ -1362,8 +1470,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { return; } - keyboardInteractiveResponded = true; - connectionLogs.push( createConnectionLog( "info", @@ -1445,8 +1551,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { return; } - keyboardInteractiveResponded = true; - pendingTOTPSessions[sessionId] = { client, finish, @@ -1485,76 +1589,33 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, ); - if ( + const proxyConfig: SOCKS5Config | null = useSocks5 && - (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0)) - ) { - connectionLogs.push( - createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy", { - proxyHost: socks5Host, - proxyPort: socks5Port || 1080, - }), - ); - try { - const socks5Socket = await createSocks5Connection(ip, port, { - useSocks5, - socks5Host, - socks5Port, - socks5Username, - socks5Password, - socks5ProxyChain: socks5ProxyChain as any, - }); + (socks5Host || + (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) + ? { + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain: socks5ProxyChain as ProxyNode[], + } + : null; - if (socks5Socket) { + const hasJumpHosts = jumpHosts && jumpHosts.length > 0 && userId; + + if (hasJumpHosts) { + try { + if (proxyConfig) { connectionLogs.push( createConnectionLog( - "success", + "info", "proxy", - "SOCKS5 proxy connected successfully", - ), - ); - config.sock = socks5Socket; - client.connect(config); - return; - } else { - fileLogger.error("SOCKS5 socket is null for SFTP", undefined, { - operation: "sftp_socks5_socket_null", - sessionId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - "SOCKS5 socket creation returned null", + "Connecting via proxy + jump hosts", ), ); } - } catch (socks5Error) { - fileLogger.error("SOCKS5 connection failed", socks5Error, { - operation: "socks5_connect", - sessionId, - hostId, - proxyHost: socks5Host, - proxyPort: socks5Port || 1080, - }); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `SOCKS5 proxy connection failed: ${socks5Error instanceof Error ? socks5Error.message : "Unknown error"}`, - ), - ); - return res.status(500).json({ - error: - "SOCKS5 proxy connection failed: " + - (socks5Error instanceof Error - ? socks5Error.message - : "Unknown error"), - connectionLogs, - }); - } - } else if (jumpHosts && jumpHosts.length > 0 && userId) { - try { connectionLogs.push( createConnectionLog( "info", @@ -1562,7 +1623,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { `Connecting via ${jumpHosts.length} jump host(s)`, ), ); - const jumpClient = await createJumpHostChain(jumpHosts, userId); + const jumpClient = await createJumpHostChain( + jumpHosts, + userId, + proxyConfig, + ); if (!jumpClient) { fileLogger.error("Failed to establish jump host chain", { @@ -1627,6 +1692,48 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { connectionLogs, }); } + } else if (proxyConfig) { + connectionLogs.push( + createConnectionLog("info", "proxy", "Connecting via proxy", { + proxyHost: socks5Host, + proxyPort: socks5Port || 1080, + }), + ); + try { + const proxySocket = await createSocks5Connection(ip, port, proxyConfig); + if (proxySocket) { + connectionLogs.push( + createConnectionLog( + "success", + "proxy", + "Proxy connected successfully", + ), + ); + config.sock = proxySocket; + } + client.connect(config); + } catch (proxyError) { + fileLogger.error("Proxy connection failed", proxyError, { + operation: "proxy_connect", + sessionId, + hostId, + proxyHost: socks5Host, + proxyPort: socks5Port || 1080, + }); + connectionLogs.push( + createConnectionLog( + "error", + "proxy", + `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, + ), + ); + return res.status(500).json({ + error: + "Proxy connection failed: " + + (proxyError instanceof Error ? proxyError.message : "Unknown error"), + connectionLogs, + }); + } } else { client.connect(config); } @@ -1687,7 +1794,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch (error) {} + } catch { + // expected + } fileLogger.warn("TOTP session timeout before code submission", { operation: "file_totp_verify", sessionId, @@ -1710,7 +1819,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { }); let responseSent = false; - let responseTimeout: NodeJS.Timeout; session.client.once("ready", () => { if (responseSent) return; @@ -1799,7 +1907,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { res.status(401).json({ status: "error", message: "Invalid TOTP code" }); }); - responseTimeout = setTimeout(() => { + const responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; @@ -1884,7 +1992,9 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch (error) {} + } catch { + // expected + } fileLogger.warn("Warpgate session timeout before completion", { operation: "file_warpgate_verify", sessionId, @@ -1897,7 +2007,19 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { } let responseSent = false; - let responseTimeout: NodeJS.Timeout; + + const responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + fileLogger.warn("Warpgate verification timeout", { + operation: "file_warpgate_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "Warpgate verification timeout" }); + } + }, 60000); session.client.once("ready", () => { if (responseSent) return; @@ -1987,19 +2109,6 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { .json({ status: "error", message: "Warpgate authentication failed" }); }); - responseTimeout = setTimeout(() => { - if (!responseSent) { - responseSent = true; - delete pendingTOTPSessions[sessionId]; - fileLogger.warn("Warpgate verification timeout", { - operation: "file_warpgate_verify", - sessionId, - userId, - }); - res.status(408).json({ error: "Warpgate verification timeout" }); - } - }, 60000); - session.finish([""]); }); @@ -2188,26 +2297,285 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { userId, path: sshPath, }); - sshConn.client.sftp((err, sftp) => { - if (err) { + getSessionSftp(sshConn) + .then((sftp) => { + sftp.readdir(sshPath, (readdirErr, list) => { + if (readdirErr) { + fileLogger.warn( + `SFTP readdir failed, trying fallback: ${readdirErr.message}`, + ); + tryFallbackMethod(); + return; + } + + const symlinks: Array<{ index: number; path: string }> = []; + const files: Array<{ + name: string; + type: string; + size: number | undefined; + modified: string; + permissions: string; + owner: string; + group: string; + linkTarget: string | undefined; + path: string; + executable: boolean; + }> = []; + + for (const entry of list) { + if (entry.filename === "." || entry.filename === "..") continue; + + const attrs = entry.attrs; + const permissions = modeToPermissions(attrs.mode); + const isDirectory = attrs.isDirectory(); + const isLink = attrs.isSymbolicLink(); + + const fileEntry = { + name: entry.filename, + type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : attrs.size, + modified: formatMtime(attrs.mtime), + permissions, + owner: String(attrs.uid), + group: String(attrs.gid), + linkTarget: undefined as string | undefined, + path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`, + executable: + !isDirectory && !isLink + ? isExecutableFile(permissions, entry.filename) + : false, + }; + + if (isLink) { + symlinks.push({ index: files.length, path: fileEntry.path }); + } + + files.push(fileEntry); + } + + if (symlinks.length === 0) { + sshConn.activeOperations--; + return res.json({ files, path: sshPath }); + } + + let resolved = 0; + let responded = false; + + const sendResponse = () => { + if (responded) return; + responded = true; + sshConn.activeOperations--; + res.json({ files, path: sshPath }); + }; + + const readlinkTimeout = setTimeout(sendResponse, 5000); + + for (const link of symlinks) { + sftp.readlink(link.path, (linkErr, target) => { + resolved++; + if (!linkErr && target) { + files[link.index].linkTarget = target; + } + if (resolved === symlinks.length) { + clearTimeout(readlinkTimeout); + sendResponse(); + } + }); + } + }); + }) + .catch((err: Error) => { fileLogger.warn( `SFTP failed for listFiles, trying fallback: ${err.message}`, ); tryFallbackMethod(); - return; - } + }); + } catch (sftpErr: unknown) { + const errMsg = + sftpErr instanceof Error ? sftpErr.message : "Unknown error"; + fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`); + tryFallbackMethod(); + } + }; - sftp.readdir(sshPath, (readdirErr, list) => { - if (readdirErr) { - sftp.end(); - fileLogger.warn( - `SFTP readdir failed, trying fallback: ${readdirErr.message}`, - ); - tryFallbackMethod(); - return; + const tryFallbackMethod = () => { + try { + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); + sshConn.client.exec( + `command ls -la --color=never '${escapedPath}'`, + (err, stream) => { + if (err) { + sshConn.activeOperations--; + fileLogger.error("SSH listFiles error:", err); + return res.status(500).json({ error: err.message }); } - const symlinks: Array<{ index: number; path: string }> = []; + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + const isPermissionDenied = + errorData.toLowerCase().includes("permission denied") || + errorData.toLowerCase().includes("access denied"); + + if (isPermissionDenied) { + if (sshConn.sudoPassword) { + fileLogger.info( + `Permission denied for listFiles, retrying with sudo: ${sshPath}`, + ); + tryWithSudo(); + return; + } + + sshConn.activeOperations--; + fileLogger.warn( + `Permission denied for listFiles, sudo required: ${sshPath}`, + ); + return res.status(403).json({ + error: `Permission denied: Cannot access ${sshPath}`, + needsSudo: true, + path: sshPath, + }); + } + + sshConn.activeOperations--; + fileLogger.error( + `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res + .status(500) + .json({ error: `Command failed: ${errorData}` }); + } + sshConn.activeOperations--; + + const lines = data.split("\n").filter((line) => line.trim()); + const files = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + if (parts.length >= 9) { + const permissions = parts[0]; + const owner = parts[2]; + const group = parts[3]; + const size = parseInt(parts[4], 10); + + let dateStr = ""; + const nameStartIndex = 8; + + if (parts[5] && parts[6] && parts[7]) { + dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; + } + + const name = parts.slice(nameStartIndex).join(" "); + const isDirectory = permissions.startsWith("d"); + const isLink = permissions.startsWith("l"); + + if (name === "." || name === "..") continue; + + let actualName = name; + let linkTarget = undefined; + if (isLink && name.includes(" -> ")) { + const linkParts = name.split(" -> "); + actualName = linkParts[0]; + linkTarget = linkParts[1]; + } + + files.push({ + name: actualName, + type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : size, + modified: dateStr, + permissions, + owner, + group, + linkTarget, + path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, + executable: + !isDirectory && !isLink + ? isExecutableFile(permissions, actualName) + : false, + }); + } + } + + res.json({ files, path: sshPath }); + }); + }, + ); + } catch (execErr: unknown) { + sshConn.activeOperations--; + const errMsg = + execErr instanceof Error ? execErr.message : "Unknown error"; + fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`); + if (!res.headersSent) { + return res.status(500).json({ error: errMsg }); + } + } + }; + + const tryWithSudo = () => { + try { + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); + const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); + const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`; + + sshConn.client.exec(sudoCommand, (err, stream) => { + if (err) { + sshConn.activeOperations--; + fileLogger.error("SSH sudo listFiles error:", err); + return res.status(500).json({ error: err.message }); + } + + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + sshConn.activeOperations--; + + data = data.replace(/\[sudo\] password for .+?:\s*/g, ""); + + if ( + data.toLowerCase().includes("sorry, try again") || + data.toLowerCase().includes("incorrect password") || + errorData.toLowerCase().includes("sorry, try again") + ) { + sshConn.sudoPassword = undefined; + return res.status(403).json({ + error: "Sudo authentication failed. Please try again.", + needsSudo: true, + sudoFailed: true, + path: sshPath, + }); + } + + if (code !== 0 && !data.trim()) { + fileLogger.error( + `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res + .status(500) + .json({ error: `Sudo command failed: ${errorData || data}` }); + } + + const lines = data.split("\n").filter((line) => line.trim()); const files: Array<{ name: string; type: string; @@ -2221,308 +2589,68 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { executable: boolean; }> = []; - for (const entry of list) { - if (entry.filename === "." || entry.filename === "..") continue; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + if (parts.length >= 9) { + const permissions = parts[0]; + const owner = parts[2]; + const group = parts[3]; + const size = parseInt(parts[4], 10); - const attrs = entry.attrs; - const permissions = modeToPermissions(attrs.mode); - const isDirectory = attrs.isDirectory(); - const isLink = attrs.isSymbolicLink(); + let dateStr = ""; + const nameStartIndex = 8; - const fileEntry = { - name: entry.filename, - type: isDirectory ? "directory" : isLink ? "link" : "file", - size: isDirectory ? undefined : attrs.size, - modified: formatMtime(attrs.mtime), - permissions, - owner: String(attrs.uid), - group: String(attrs.gid), - linkTarget: undefined as string | undefined, - path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`, - executable: - !isDirectory && !isLink - ? isExecutableFile(permissions, entry.filename) - : false, - }; + if (parts[5] && parts[6] && parts[7]) { + dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; + } - if (isLink) { - symlinks.push({ index: files.length, path: fileEntry.path }); + const name = parts.slice(nameStartIndex).join(" "); + const isDirectory = permissions.startsWith("d"); + const isLink = permissions.startsWith("l"); + + if (name === "." || name === "..") continue; + + let actualName = name; + let linkTarget = undefined; + if (isLink && name.includes(" -> ")) { + const linkParts = name.split(" -> "); + actualName = linkParts[0]; + linkTarget = linkParts[1]; + } + + files.push({ + name: actualName, + type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : size, + modified: dateStr, + permissions, + owner, + group, + linkTarget, + path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, + executable: + !isDirectory && !isLink + ? isExecutableFile(permissions, actualName) + : false, + }); } - - files.push(fileEntry); } - if (symlinks.length === 0) { - sftp.end(); - sshConn.activeOperations--; - return res.json({ files, path: sshPath }); - } - - let resolved = 0; - let responded = false; - - const sendResponse = () => { - if (responded) return; - responded = true; - sftp.end(); - sshConn.activeOperations--; - res.json({ files, path: sshPath }); - }; - - const readlinkTimeout = setTimeout(sendResponse, 5000); - - for (const link of symlinks) { - sftp.readlink(link.path, (linkErr, target) => { - resolved++; - if (!linkErr && target) { - files[link.index].linkTarget = target; - } - if (resolved === symlinks.length) { - clearTimeout(readlinkTimeout); - sendResponse(); - } - }); - } + res.json({ files, path: sshPath }); }); }); - } catch (sftpErr: unknown) { + } catch (execErr: unknown) { + sshConn.activeOperations--; const errMsg = - sftpErr instanceof Error ? sftpErr.message : "Unknown error"; - fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`); - tryFallbackMethod(); + execErr instanceof Error ? execErr.message : "Unknown error"; + fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`); + if (!res.headersSent) { + return res.status(500).json({ error: errMsg }); + } } }; - const tryFallbackMethod = () => { - const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => { - if (err) { - sshConn.activeOperations--; - fileLogger.error("SSH listFiles error:", err); - return res.status(500).json({ error: err.message }); - } - - let data = ""; - let errorData = ""; - - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on("close", (code) => { - if (code !== 0) { - const isPermissionDenied = - errorData.toLowerCase().includes("permission denied") || - errorData.toLowerCase().includes("access denied"); - - if (isPermissionDenied) { - if (sshConn.sudoPassword) { - fileLogger.info( - `Permission denied for listFiles, retrying with sudo: ${sshPath}`, - ); - tryWithSudo(); - return; - } - - sshConn.activeOperations--; - fileLogger.warn( - `Permission denied for listFiles, sudo required: ${sshPath}`, - ); - return res.status(403).json({ - error: `Permission denied: Cannot access ${sshPath}`, - needsSudo: true, - path: sshPath, - }); - } - - sshConn.activeOperations--; - fileLogger.error( - `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - return res - .status(500) - .json({ error: `Command failed: ${errorData}` }); - } - sshConn.activeOperations--; - - const lines = data.split("\n").filter((line) => line.trim()); - const files = []; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const parts = line.split(/\s+/); - if (parts.length >= 9) { - const permissions = parts[0]; - const owner = parts[2]; - const group = parts[3]; - const size = parseInt(parts[4], 10); - - let dateStr = ""; - const nameStartIndex = 8; - - if (parts[5] && parts[6] && parts[7]) { - dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; - } - - const name = parts.slice(nameStartIndex).join(" "); - const isDirectory = permissions.startsWith("d"); - const isLink = permissions.startsWith("l"); - - if (name === "." || name === "..") continue; - - let actualName = name; - let linkTarget = undefined; - if (isLink && name.includes(" -> ")) { - const linkParts = name.split(" -> "); - actualName = linkParts[0]; - linkTarget = linkParts[1]; - } - - files.push({ - name: actualName, - type: isDirectory ? "directory" : isLink ? "link" : "file", - size: isDirectory ? undefined : size, - modified: dateStr, - permissions, - owner, - group, - linkTarget, - path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, - executable: - !isDirectory && !isLink - ? isExecutableFile(permissions, actualName) - : false, - }); - } - } - - res.json({ files, path: sshPath }); - }); - }); - }; - - const tryWithSudo = () => { - const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); - const sudoCommand = `echo '${escapedPassword}' | sudo -S ls -la '${escapedPath}' 2>&1`; - - sshConn.client.exec(sudoCommand, (err, stream) => { - if (err) { - sshConn.activeOperations--; - fileLogger.error("SSH sudo listFiles error:", err); - return res.status(500).json({ error: err.message }); - } - - let data = ""; - let errorData = ""; - - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - }); - - stream.on("close", (code) => { - sshConn.activeOperations--; - - data = data.replace(/\[sudo\] password for .+?:\s*/g, ""); - - if ( - data.toLowerCase().includes("sorry, try again") || - data.toLowerCase().includes("incorrect password") || - errorData.toLowerCase().includes("sorry, try again") - ) { - sshConn.sudoPassword = undefined; - return res.status(403).json({ - error: "Sudo authentication failed. Please try again.", - needsSudo: true, - sudoFailed: true, - path: sshPath, - }); - } - - if (code !== 0 && !data.trim()) { - fileLogger.error( - `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - return res - .status(500) - .json({ error: `Sudo command failed: ${errorData || data}` }); - } - - const lines = data.split("\n").filter((line) => line.trim()); - const files: Array<{ - name: string; - type: string; - size: number | undefined; - modified: string; - permissions: string; - owner: string; - group: string; - linkTarget: string | undefined; - path: string; - executable: boolean; - }> = []; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const parts = line.split(/\s+/); - if (parts.length >= 9) { - const permissions = parts[0]; - const owner = parts[2]; - const group = parts[3]; - const size = parseInt(parts[4], 10); - - let dateStr = ""; - const nameStartIndex = 8; - - if (parts[5] && parts[6] && parts[7]) { - dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; - } - - const name = parts.slice(nameStartIndex).join(" "); - const isDirectory = permissions.startsWith("d"); - const isLink = permissions.startsWith("l"); - - if (name === "." || name === "..") continue; - - let actualName = name; - let linkTarget = undefined; - if (isLink && name.includes(" -> ")) { - const linkParts = name.split(" -> "); - actualName = linkParts[0]; - linkTarget = linkParts[1]; - } - - files.push({ - name: actualName, - type: isDirectory ? "directory" : isLink ? "link" : "file", - size: isDirectory ? undefined : size, - modified: dateStr, - permissions, - owner, - group, - linkTarget, - path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, - executable: - !isDirectory && !isLink - ? isExecutableFile(permissions, actualName) - : false, - }); - } - } - - res.json({ files, path: sshPath }); - }); - }); - }; - trySFTP(); }); @@ -2620,6 +2748,98 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/resolvePath: + * get: + * summary: Resolve a path with environment variables + * description: Expands environment variables and ~ in a path via the SSH session. + * tags: + * - File Manager + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The resolved absolute path. + * 400: + * description: Missing required parameters. + * 500: + * description: Failed to resolve path. + */ +app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const rawPath = decodeURIComponent(req.query.path as string); + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!rawPath) { + return res.status(400).json({ error: "Path is required" }); + } + + sshConn.lastActive = Date.now(); + + let expandPath = rawPath; + if (expandPath.startsWith("~")) { + expandPath = "$HOME" + expandPath.substring(1); + } + + const escapedPath = expandPath.replace(/"/g, '\\"'); + const command = `echo "${escapedPath}"`; + + sshConn.client.exec(command, (err, stream) => { + if (err) { + fileLogger.error("SSH resolvePath error:", err); + return res.status(500).json({ error: err.message }); + } + + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH resolvePath command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res.json({ resolvedPath: rawPath }); + } + + const resolved = data.trim(); + res.json({ resolvedPath: resolved || rawPath }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH resolvePath stream error:", streamErr); + if (!res.headersSent) { + res.json({ resolvedPath: rawPath }); + } + }); + }); +}); + /** * @openapi * /ssh/file_manager/ssh/readFile: @@ -2870,113 +3090,115 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { userId, path: filePath, }); - sshConn.client.sftp((err, sftp) => { - if (err) { + getSessionSftp(sshConn) + .then((sftp) => { + let fileBuffer; + try { + if (typeof content === "string") { + try { + const testBuffer = Buffer.from(content, "base64"); + if (testBuffer.toString("base64") === content) { + fileBuffer = testBuffer; + } else { + fileBuffer = Buffer.from(content, "utf8"); + } + } catch { + fileBuffer = Buffer.from(content, "utf8"); + } + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + fileLogger.error("Buffer conversion error:", bufferErr); + if (!res.headersSent) { + return res + .status(500) + .json({ error: "Invalid file content format" }); + } + return; + } + + const writeStream = sftp.createWriteStream(filePath); + + let hasError = false; + let hasFinished = false; + + writeStream.on("error", (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write failed, trying fallback method: ${streamErr.message}`, + ); + tryFallbackMethod(); + }); + + writeStream.on("finish", () => { + if (hasError || hasFinished) return; + hasFinished = true; + fileLogger.success("File written successfully", { + operation: "file_write_success", + sessionId, + userId, + path: filePath, + bytes: fileBuffer.length, + }); + if (!res.headersSent) { + res.json({ + message: "File written successfully", + path: filePath, + toast: { + type: "success", + message: `File written: ${filePath}`, + }, + }); + } + }); + + writeStream.on("close", () => { + if (hasError || hasFinished) return; + hasFinished = true; + fileLogger.success("File written successfully", { + operation: "file_write_success", + sessionId, + userId, + path: filePath, + bytes: fileBuffer.length, + }); + if (!res.headersSent) { + res.json({ + message: "File written successfully", + path: filePath, + toast: { + type: "success", + message: `File written: ${filePath}`, + }, + }); + } + }); + + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write operation failed, trying fallback method: ${writeErr.message}`, + ); + tryFallbackMethod(); + } + }) + .catch((err: Error) => { fileLogger.warn( `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); - return; - } - - let fileBuffer; - try { - if (typeof content === "string") { - try { - const testBuffer = Buffer.from(content, "base64"); - if (testBuffer.toString("base64") === content) { - fileBuffer = testBuffer; - } else { - fileBuffer = Buffer.from(content, "utf8"); - } - } catch { - fileBuffer = Buffer.from(content, "utf8"); - } - } else if (Buffer.isBuffer(content)) { - fileBuffer = content; - } else { - fileBuffer = Buffer.from(content); - } - } catch (bufferErr) { - fileLogger.error("Buffer conversion error:", bufferErr); - if (!res.headersSent) { - return res - .status(500) - .json({ error: "Invalid file content format" }); - } - return; - } - - const writeStream = sftp.createWriteStream(filePath); - - let hasError = false; - let hasFinished = false; - - writeStream.on("error", (streamErr) => { - if (hasError || hasFinished) return; - hasError = true; - sftp.end(); - fileLogger.warn( - `SFTP write failed, trying fallback method: ${streamErr.message}`, - ); - tryFallbackMethod(); }); - - writeStream.on("finish", () => { - if (hasError || hasFinished) return; - hasFinished = true; - sftp.end(); - fileLogger.success("File written successfully", { - operation: "file_write_success", - sessionId, - userId, - path: filePath, - bytes: fileBuffer.length, - }); - if (!res.headersSent) { - res.json({ - message: "File written successfully", - path: filePath, - toast: { type: "success", message: `File written: ${filePath}` }, - }); - } - }); - - writeStream.on("close", () => { - if (hasError || hasFinished) return; - hasFinished = true; - sftp.end(); - fileLogger.success("File written successfully", { - operation: "file_write_success", - sessionId, - userId, - path: filePath, - bytes: fileBuffer.length, - }); - if (!res.headersSent) { - res.json({ - message: "File written successfully", - path: filePath, - toast: { type: "success", message: `File written: ${filePath}` }, - }); - } - }); - - try { - writeStream.write(fileBuffer); - writeStream.end(); - } catch (writeErr) { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn( - `SFTP write operation failed, trying fallback method: ${writeErr.message}`, - ); - tryFallbackMethod(); - } - }); } catch (sftpErr) { fileLogger.warn( - `SFTP connection error, trying fallback method: ${sftpErr.message}`, + `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`, ); tryFallbackMethod(); } @@ -3154,113 +3376,115 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { userId, path: fullPath, }); - sshConn.client.sftp((err, sftp) => { - if (err) { + getSessionSftp(sshConn) + .then((sftp) => { + let fileBuffer; + try { + if (typeof content === "string") { + fileBuffer = Buffer.from(content, "base64"); + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + fileLogger.error("Buffer conversion error:", bufferErr); + if (!res.headersSent) { + return res + .status(500) + .json({ error: "Invalid file content format" }); + } + return; + } + + const writeStream = sftp.createWriteStream(fullPath); + + let hasError = false; + let hasFinished = false; + + writeStream.on("error", (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write failed, trying fallback method: ${streamErr.message}`, + { + operation: "file_upload", + sessionId, + fileName, + fileSize: contentSize, + error: streamErr.message, + }, + ); + tryFallbackMethod(); + }); + + writeStream.on("finish", () => { + if (hasError || hasFinished) return; + hasFinished = true; + fileLogger.success("File upload completed", { + operation: "file_upload_complete", + sessionId, + userId, + path: fullPath, + bytes: fileBuffer.length, + duration: Date.now() - uploadStartTime, + }); + if (!res.headersSent) { + res.json({ + message: "File uploaded successfully", + path: fullPath, + toast: { + type: "success", + message: `File uploaded: ${fullPath}`, + }, + }); + } + }); + + writeStream.on("close", () => { + if (hasError || hasFinished) return; + hasFinished = true; + fileLogger.success("File upload completed", { + operation: "file_upload_complete", + sessionId, + userId, + path: fullPath, + bytes: fileBuffer.length, + duration: Date.now() - uploadStartTime, + }); + if (!res.headersSent) { + res.json({ + message: "File uploaded successfully", + path: fullPath, + toast: { + type: "success", + message: `File uploaded: ${fullPath}`, + }, + }); + } + }); + + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + fileLogger.warn( + `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`, + ); + tryFallbackMethod(); + } + }) + .catch((err: Error) => { fileLogger.warn( `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); - return; - } - - let fileBuffer; - try { - if (typeof content === "string") { - fileBuffer = Buffer.from(content, "base64"); - } else if (Buffer.isBuffer(content)) { - fileBuffer = content; - } else { - fileBuffer = Buffer.from(content); - } - } catch (bufferErr) { - fileLogger.error("Buffer conversion error:", bufferErr); - if (!res.headersSent) { - return res - .status(500) - .json({ error: "Invalid file content format" }); - } - return; - } - - const writeStream = sftp.createWriteStream(fullPath); - - let hasError = false; - let hasFinished = false; - - writeStream.on("error", (streamErr) => { - if (hasError || hasFinished) return; - hasError = true; - sftp.end(); - fileLogger.warn( - `SFTP write failed, trying fallback method: ${streamErr.message}`, - { - operation: "file_upload", - sessionId, - fileName, - fileSize: contentSize, - error: streamErr.message, - }, - ); - tryFallbackMethod(); }); - - writeStream.on("finish", () => { - if (hasError || hasFinished) return; - hasFinished = true; - sftp.end(); - fileLogger.success("File upload completed", { - operation: "file_upload_complete", - sessionId, - userId, - path: fullPath, - bytes: fileBuffer.length, - duration: Date.now() - uploadStartTime, - }); - if (!res.headersSent) { - res.json({ - message: "File uploaded successfully", - path: fullPath, - toast: { type: "success", message: `File uploaded: ${fullPath}` }, - }); - } - }); - - writeStream.on("close", () => { - if (hasError || hasFinished) return; - hasFinished = true; - sftp.end(); - fileLogger.success("File upload completed", { - operation: "file_upload_complete", - sessionId, - userId, - path: fullPath, - bytes: fileBuffer.length, - duration: Date.now() - uploadStartTime, - }); - if (!res.headersSent) { - res.json({ - message: "File uploaded successfully", - path: fullPath, - toast: { type: "success", message: `File uploaded: ${fullPath}` }, - }); - } - }); - - try { - writeStream.write(fileBuffer); - writeStream.end(); - } catch (writeErr) { - if (hasError || hasFinished) return; - hasError = true; - fileLogger.warn( - `SFTP write operation failed, trying fallback method: ${writeErr.message}`, - ); - tryFallbackMethod(); - } - }); } catch (sftpErr) { fileLogger.warn( - `SFTP connection error, trying fallback method: ${sftpErr.message}`, + `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`, ); tryFallbackMethod(); } @@ -4285,80 +4509,77 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { path: filePath, }); - sshConn.client.sftp((err, sftp) => { - if (err) { - fileLogger.error("SFTP connection failed for download:", err); - return res.status(500).json({ error: "SFTP connection failed" }); - } - - sftp.stat(filePath, (statErr, stats) => { - if (statErr) { - sftp.end(); - fileLogger.error("File stat failed for download:", statErr); - return res - .status(500) - .json({ error: `Cannot access file: ${statErr.message}` }); - } - - if (!stats.isFile()) { - fileLogger.warn("Attempted to download non-file", { - operation: "file_download", - sessionId, - filePath, - isFile: stats.isFile(), - isDirectory: stats.isDirectory(), - }); - return res - .status(400) - .json({ error: "Cannot download directories or special files" }); - } - - const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; - if (stats.size > MAX_FILE_SIZE) { - fileLogger.warn("File too large for download", { - operation: "file_download", - sessionId, - filePath, - fileSize: stats.size, - maxSize: MAX_FILE_SIZE, - }); - return res.status(400).json({ - error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`, - }); - } - - sftp.readFile(filePath, (readErr, data) => { - if (readErr) { - sftp.end(); - fileLogger.error("File read failed for download:", readErr); + getSessionSftp(sshConn) + .then((sftp) => { + sftp.stat(filePath, (statErr, stats) => { + if (statErr) { + fileLogger.error("File stat failed for download:", statErr); return res .status(500) - .json({ error: `Failed to read file: ${readErr.message}` }); + .json({ error: `Cannot access file: ${statErr.message}` }); } - sftp.end(); - const base64Content = data.toString("base64"); - const fileName = filePath.split("/").pop() || "download"; - fileLogger.success("File download completed", { - operation: "file_download_complete", - sessionId, - userId, - hostId, - path: filePath, - bytes: stats.size, - duration: Date.now() - downloadStartTime, - }); + if (!stats.isFile()) { + fileLogger.warn("Attempted to download non-file", { + operation: "file_download", + sessionId, + filePath, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + }); + return res + .status(400) + .json({ error: "Cannot download directories or special files" }); + } - res.json({ - content: base64Content, - fileName: fileName, - size: stats.size, - mimeType: getMimeType(fileName), - path: filePath, + const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; + if (stats.size > MAX_FILE_SIZE) { + fileLogger.warn("File too large for download", { + operation: "file_download", + sessionId, + filePath, + fileSize: stats.size, + maxSize: MAX_FILE_SIZE, + }); + return res.status(400).json({ + error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`, + }); + } + + sftp.readFile(filePath, (readErr, data) => { + if (readErr) { + fileLogger.error("File read failed for download:", readErr); + return res + .status(500) + .json({ error: `Failed to read file: ${readErr.message}` }); + } + + const base64Content = data.toString("base64"); + const fileName = filePath.split("/").pop() || "download"; + fileLogger.success("File download completed", { + operation: "file_download_complete", + sessionId, + userId, + hostId, + path: filePath, + bytes: stats.size, + duration: Date.now() - downloadStartTime, + }); + + res.json({ + content: base64Content, + fileName: fileName, + size: stats.size, + mimeType: getMimeType(fileName), + path: filePath, + }); }); }); + }) + .catch((err) => { + fileLogger.error("SFTP connection failed for download:", err); + return res.status(500).json({ error: "SFTP connection failed" }); }); - }); }); /** @@ -4980,7 +5201,9 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { let errorOutput = ""; - stream.on("data", (data: Buffer) => {}); + stream.on("data", () => { + /* consume stdout */ + }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); @@ -5192,7 +5415,9 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { let errorOutput = ""; - stream.on("data", (data: Buffer) => {}); + stream.on("data", () => { + /* consume stdout */ + }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/ssh/host-key-verifier.ts index a9057f8f..81ed056f 100644 --- a/src/backend/ssh/host-key-verifier.ts +++ b/src/backend/ssh/host-key-verifier.ts @@ -3,7 +3,6 @@ import { db } from "../database/db/index.js"; import { sshData } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; -import crypto from "crypto"; interface HostKeyVerificationData { scenario: "new" | "changed"; diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts index 317617d4..cf3f08bc 100644 --- a/src/backend/ssh/opkssh-auth.ts +++ b/src/backend/ssh/opkssh-auth.ts @@ -51,7 +51,9 @@ const cleanupInProgress = new Set(); export function getRequestOrigin(req: IncomingMessage): string { const protoHeader = req.headers["x-forwarded-proto"] || - ((req.socket as any).encrypted ? "https" : "http"); + ((req.socket as unknown as { encrypted?: boolean }).encrypted + ? "https" + : "http"); const proto = typeof protoHeader === "string" ? protoHeader.split(",")[0].trim() @@ -157,7 +159,7 @@ async function checkOPKConfigExists(): Promise<{ return { exists: false, configPath, - error: `OPKSSH configuration is missing 'redirect_uris' field.`, + error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/ssh/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`, }; } @@ -235,11 +237,11 @@ export async function startOPKSSHAuth( try { const binaryPath = OPKSSHBinaryManager.getBinaryPath(); const configPath = getOPKConfigPath(); - const configDir = path.dirname(configPath); const args = [ "login", "--print-key", + "--disable-browser-open", `--config-path=${configPath}`, `--remote-redirect-uri=${remoteRedirectUri}`, ]; @@ -284,7 +286,10 @@ export async function startOPKSSHAuth( opksshProcess.stderr?.on("data", async (data) => { const stderr = data.toString(); - if (stderr.includes("Opening browser to")) { + if ( + stderr.includes("Opening browser to") || + stderr.includes("Open your browser to:") + ) { handleOPKSSHOutput(requestId, stderr); } @@ -371,7 +376,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void { session.stdoutBuffer += output; const chooserUrlMatch = session.stdoutBuffer.match( - /Opening browser to http:\/\/localhost:(\d+)\/chooser/, + /(?:Opening browser to|Open your browser to:)\s*http:\/\/localhost:(\d+)\/chooser/, ); if (chooserUrlMatch && session.status === "starting") { const actualPort = parseInt(chooserUrlMatch[1], 10); @@ -784,7 +789,10 @@ export function getActiveSessionsAll(): OPKSSHAuthSession[] { return Array.from(activeAuthSessions.values()); } -export async function getUserIdFromRequest(req: any): Promise { +export async function getUserIdFromRequest(req: { + cookies?: Record; + headers: Record; +}): Promise { try { const { AuthManager } = await import("../utils/auth-manager.js"); const authManager = AuthManager.getInstance(); @@ -797,7 +805,7 @@ export async function getUserIdFromRequest(req: any): Promise { const decoded = await authManager.verifyJWTToken(token); return decoded?.userId || null; - } catch (error) { + } catch { return null; } } diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 1b50a8fe..6817cf63 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,7 +6,7 @@ import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { statsLogger, sshLogger } from "../utils/logger.js"; +import { statsLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; @@ -22,14 +22,18 @@ import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../utils/socks5-helper.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; +import { connectionPool, withConnection } from "./ssh-connection-pool.js"; function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, - details?: Record, + details?: Record, ): Omit { return { type, @@ -39,10 +43,24 @@ function createConnectionLog( }; } +interface JumpHostConfig { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + [key: string]: unknown; +} + async function resolveJumpHost( hostId: number, userId: string, -): Promise { +): Promise { try { const hosts = await SimpleDBOps.select( getDb() @@ -78,17 +96,16 @@ async function resolveJumpHost( const credential = credentials[0]; return { ...host, - password: credential.password, - key: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - keyType: credential.key_type || credential.keyType, - authType: credential.auth_type || credential.authType, - }; + password: credential.password as string | undefined, + key: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authType: credential.authType as string | undefined, + } as JumpHostConfig; } } - return host; + return host as JumpHostConfig; } catch (error) { statsLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", @@ -102,6 +119,7 @@ async function resolveJumpHost( async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, + socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -111,17 +129,40 @@ async function createJumpHostChain( const clients: Client[] = []; try { + const jumpHostConfigs: Array>> = + []; for (let i = 0; i < jumpHosts.length; i++) { - const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + const config = await resolveJumpHost(jumpHosts[i].hostId, userId); + jumpHostConfigs.push(config); + } - if (!jumpHostConfig) { + const totalHops = jumpHostConfigs.length; + + for (let i = 0; i < jumpHostConfigs.length; i++) { + if (!jumpHostConfigs[i]) { statsLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, + hopIndex: i, + totalHops, }); clients.forEach((c) => c.end()); return null; } + } + + let proxySocket: import("net").Socket | null = null; + if (socks5Config?.useSocks5) { + const firstHop = jumpHostConfigs[0]!; + proxySocket = await createSocks5Connection( + firstHop.ip, + firstHop.port || 22, + socks5Config, + ); + } + + for (let i = 0; i < jumpHostConfigs.length; i++) { + const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new Client(); clients.push(jumpClient); @@ -147,16 +188,29 @@ async function createJumpHostChain( jumpClient.on("error", (err) => { clearTimeout(timeout); - statsLogger.error(`Jump host ${i + 1} connection failed`, err, { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - }); + statsLogger.error( + `Jump host ${i + 1}/${totalHops} connection failed`, + err, + { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + hopIndex: i, + totalHops, + previousHop: + i > 0 + ? jumpHostConfigs[i - 1]?.ip + : proxySocket + ? "proxy" + : "direct", + usedProxySocket: i === 0 && !!proxySocket, + }, + ); resolve(false); }); - const connectConfig: any = { - host: jumpHostConfig.ip, + const connectConfig: Record = { + host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, @@ -193,6 +247,9 @@ async function createJumpHostChain( jumpClient.connect(connectConfig); }, ); + } else if (proxySocket) { + connectConfig.sock = proxySocket; + jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } @@ -216,13 +273,6 @@ async function createJumpHostChain( } } -interface PooledConnection { - client: Client; - lastUsed: number; - inUse: boolean; - hostKey: string; -} - interface MetricsSession { client: Client; isConnected: boolean; @@ -275,7 +325,9 @@ function cleanupMetricsSession(sessionId: string) { try { session.client.end(); - } catch (error) {} + } catch { + // expected + } clearTimeout(session.timeout); delete metricsSessions[sessionId]; } @@ -299,355 +351,6 @@ function getSessionKey(hostId: number, userId: string): string { return `${userId}:${hostId}`; } -class SSHConnectionPool { - private connections = new Map(); - private maxConnectionsPerHost = 3; - private connectionTimeout = 30000; - private cleanupInterval: NodeJS.Timeout; - - constructor() { - this.cleanupInterval = setInterval( - () => { - this.cleanup(); - }, - 2 * 60 * 1000, - ); - } - - private getHostKey(host: SSHHostWithCredentials): string { - const socks5Key = host.useSocks5 - ? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}` - : ""; - return `${host.ip}:${host.port}:${host.username}${socks5Key}`; - } - - private isConnectionHealthy(client: Client): boolean { - try { - const sock = (client as any)._sock; - if (sock && (sock.destroyed || !sock.writable)) { - return false; - } - return true; - } catch (error) { - return false; - } - } - - async getConnection(host: SSHHostWithCredentials): Promise { - const hostKey = this.getHostKey(host); - let connections = this.connections.get(hostKey) || []; - - const available = connections.find((conn) => !conn.inUse); - if (available) { - if (!this.isConnectionHealthy(available.client)) { - statsLogger.warn("Removing unhealthy connection from pool", { - operation: "remove_dead_connection", - hostKey, - }); - try { - available.client.end(); - } catch (error) { - // Ignore cleanup errors - } - connections = connections.filter((c) => c !== available); - this.connections.set(hostKey, connections); - } else { - available.inUse = true; - available.lastUsed = Date.now(); - return available.client; - } - } - - if (connections.length < this.maxConnectionsPerHost) { - const client = await this.createConnection(host); - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey, - }; - connections.push(pooled); - this.connections.set(hostKey, connections); - return client; - } - - return new Promise((resolve) => { - const checkAvailable = () => { - const available = connections.find((conn) => !conn.inUse); - if (available) { - available.inUse = true; - available.lastUsed = Date.now(); - resolve(available.client); - } else { - setTimeout(checkAvailable, 100); - } - }; - checkAvailable(); - }); - } - - private async createConnection( - host: SSHHostWithCredentials, - ): Promise { - const config = await buildSshConfig(host); - return new Promise(async (resolve, reject) => { - const client = new Client(); - const timeout = setTimeout(() => { - client.end(); - reject(new Error("SSH connection timeout")); - }, this.connectionTimeout); - - client.on("ready", () => { - clearTimeout(timeout); - resolve(client); - }); - - client.on("error", (err) => { - clearTimeout(timeout); - reject(err); - }); - - client.on( - "keyboard-interactive", - ( - name: string, - instructions: string, - instructionsLang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId: host.id, - userId: host.userId!, - prompts: prompts.map((p) => ({ - prompt: p.prompt, - echo: p.echo ?? false, - })), - totpPromptIndex, - resolvedPassword: host.password, - totpAttempts: 0, - }; - - return; - } else if (host.password) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt)) { - return host.password || ""; - } - return ""; - }); - finish(responses); - } else { - finish(prompts.map(() => "")); - } - }, - ); - - try { - if ( - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ) { - try { - const socks5Socket = await createSocks5Connection( - host.ip, - host.port, - { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - }, - ); - - if (socks5Socket) { - config.sock = socks5Socket; - client.connect(config); - return; - } else { - statsLogger.error("SOCKS5 socket is null", undefined, { - operation: "socks5_socket_null", - hostIp: host.ip, - }); - } - } catch (socks5Error) { - clearTimeout(timeout); - statsLogger.error("SOCKS5 connection error", socks5Error, { - operation: "socks5_connection_error", - hostIp: host.ip, - errorMessage: - socks5Error instanceof Error ? socks5Error.message : "Unknown", - }); - reject( - new Error( - "SOCKS5 proxy connection failed: " + - (socks5Error instanceof Error - ? socks5Error.message - : "Unknown error"), - ), - ); - return; - } - } - - if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { - const jumpClient = await createJumpHostChain( - host.jumpHosts, - host.userId, - ); - - if (!jumpClient) { - clearTimeout(timeout); - reject(new Error("Failed to establish jump host chain")); - return; - } - - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err) { - clearTimeout(timeout); - jumpClient.end(); - reject( - new Error( - "Failed to forward through jump host: " + err.message, - ), - ); - return; - } - - config.sock = stream; - client.connect(config); - }, - ); - } else { - client.connect(config); - } - } catch (err) { - clearTimeout(timeout); - reject(err); - } - }); - } - - releaseConnection(host: SSHHostWithCredentials, client: Client): void { - const hostKey = this.getHostKey(host); - const connections = this.connections.get(hostKey) || []; - const pooled = connections.find((conn) => conn.client === client); - if (pooled) { - pooled.inUse = false; - pooled.lastUsed = Date.now(); - } - } - - clearHostConnections(host: SSHHostWithCredentials): void { - const hostKey = this.getHostKey(host); - const connections = this.connections.get(hostKey) || []; - - for (const conn of connections) { - try { - conn.client.end(); - } catch (error) { - statsLogger.error("Error closing connection during cleanup", error, { - operation: "clear_connection_error", - }); - } - } - - this.connections.delete(hostKey); - } - - private cleanup(): void { - const now = Date.now(); - const maxAge = 10 * 60 * 1000; - let totalCleaned = 0; - let totalUnhealthy = 0; - - for (const [hostKey, connections] of this.connections.entries()) { - const activeConnections = connections.filter((conn) => { - if (!conn.inUse && now - conn.lastUsed > maxAge) { - try { - conn.client.end(); - } catch (error) {} - totalCleaned++; - return false; - } - if (!this.isConnectionHealthy(conn.client)) { - statsLogger.warn("Removing unhealthy connection during cleanup", { - operation: "cleanup_unhealthy", - hostKey, - inUse: conn.inUse, - }); - try { - conn.client.end(); - } catch (error) {} - totalUnhealthy++; - return false; - } - return true; - }); - - if (activeConnections.length === 0) { - this.connections.delete(hostKey); - } else { - this.connections.set(hostKey, activeConnections); - } - } - } - - clearAllConnections(): void { - for (const [hostKey, connections] of this.connections.entries()) { - for (const conn of connections) { - try { - conn.client.end(); - } catch (error) { - statsLogger.error( - "Error closing connection during full cleanup", - error, - { - operation: "clear_all_error", - hostKey, - }, - ); - } - } - } - this.connections.clear(); - } - - destroy(): void { - clearInterval(this.cleanupInterval); - for (const connections of this.connections.values()) { - for (const conn of connections) { - try { - conn.client.end(); - } catch (error) {} - } - } - this.connections.clear(); - } -} - class RequestQueue { private queues = new Map Promise>>(); private processing = new Set(); @@ -695,7 +398,9 @@ class RequestQueue { if (request) { try { await request(); - } catch (error) {} + } catch { + // expected + } } } @@ -898,7 +603,6 @@ class PollingBackoff { } } -const connectionPool = new SSHConnectionPool(); const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); const authFailureTracker = new AuthFailureTracker(); @@ -951,14 +655,16 @@ interface StatsConfig { enabledWidgets: string[]; statusCheckEnabled: boolean; statusCheckInterval: number; + useGlobalStatusInterval?: boolean; metricsEnabled: boolean; metricsInterval: number; + useGlobalMetricsInterval?: boolean; } const DEFAULT_STATS_CONFIG: StatsConfig = { enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], statusCheckEnabled: true, - statusCheckInterval: 30, + statusCheckInterval: 60, metricsEnabled: true, metricsInterval: 30, }; @@ -991,6 +697,41 @@ class PollingManager { }, 60000); } + private getGlobalDefaults(): { + statusCheckInterval: number; + metricsInterval: number; + } { + try { + const db = getDb(); + const statusRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'global_status_check_interval'", + ) + .get() as { value: string } | undefined; + const metricsRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'global_metrics_interval'", + ) + .get() as { value: string } | undefined; + + return { + statusCheckInterval: statusRow + ? parseInt(statusRow.value, 10) || + DEFAULT_STATS_CONFIG.statusCheckInterval + : DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: metricsRow + ? parseInt(metricsRow.value, 10) || + DEFAULT_STATS_CONFIG.metricsInterval + : DEFAULT_STATS_CONFIG.metricsInterval, + }; + } catch { + return { + statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval, + }; + } + } + parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig { if (!statsConfigStr) { return DEFAULT_STATS_CONFIG; @@ -1002,13 +743,13 @@ class PollingManager { parsed = statsConfigStr; } else { try { - let temp: any = JSON.parse(statsConfigStr); + let temp: unknown = JSON.parse(statsConfigStr); if (typeof temp === "string") { temp = JSON.parse(temp); } - parsed = temp; + parsed = temp as StatsConfig; } catch (error) { statsLogger.warn( `Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -1023,6 +764,14 @@ class PollingManager { const result = { ...DEFAULT_STATS_CONFIG, ...parsed }; + const globalDefaults = this.getGlobalDefaults(); + if (result.useGlobalStatusInterval !== false) { + result.statusCheckInterval = globalDefaults.statusCheckInterval; + } + if (result.useGlobalMetricsInterval !== false) { + result.metricsInterval = globalDefaults.metricsInterval; + } + return result; } @@ -1128,7 +877,7 @@ class PollingManager { lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); - } catch (error) { + } catch { const statusEntry: StatusEntry = { status: "offline", lastChecked: new Date().toISOString(), @@ -1155,7 +904,6 @@ class PollingManager { const hasExistingMetrics = this.metricsStore.has(refreshedHost.id); if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) { - const backoffInfo = pollingBackoff.getBackoffInfo(host.id); return; } @@ -1167,9 +915,6 @@ class PollingManager { }); pollingBackoff.reset(refreshedHost.id); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - pollingBackoff.recordFailure(refreshedHost.id); const latestConfig = this.pollingConfigs.get(refreshedHost.id); @@ -1385,8 +1130,13 @@ app.use( ); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); app.use(authManager.createAuthMiddleware()); +const requireAdmin = authManager.createAdminMiddleware(); async function fetchAllHosts( userId: string, @@ -1509,32 +1259,35 @@ async function resolveHostCredentials( const isSharedHost = userId !== ownerId; if (isSharedHost) { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id as number, userId, ); - baseHost.credentialId = host.credentialId; - baseHost.authType = sharedCred.authType; + if (sharedCred) { + baseHost.credentialId = host.credentialId; + baseHost.authType = sharedCred.authType; - if (!host.overrideCredentialUsername) { - baseHost.username = sharedCred.username; - } + if (!host.overrideCredentialUsername) { + baseHost.username = sharedCred.username; + } - if (sharedCred.password) { - baseHost.password = sharedCred.password; - } - if (sharedCred.key) { - baseHost.key = sharedCred.key; - } - if (sharedCred.keyPassword) { - baseHost.keyPassword = sharedCred.keyPassword; - } - if (sharedCred.keyType) { - baseHost.keyType = sharedCred.keyType; + if (sharedCred.password) { + baseHost.password = sharedCred.password; + } + if (sharedCred.key) { + baseHost.key = sharedCred.key; + } + if (sharedCred.keyPassword) { + baseHost.keyPassword = sharedCred.keyPassword; + } + if (sharedCred.keyType) { + baseHost.keyType = sharedCred.keyType; + } } } else { const credentials = await SimpleDBOps.select( @@ -1550,12 +1303,11 @@ async function resolveHostCredentials( const credential = credentials[0]; baseHost.credentialId = credential.id; baseHost.authType = - credential.auth_type || credential.authType || (credential.password ? "password" : credential.key || - (credential as Record).private_key + (credential as Record).privateKey ? "key" : "none"); @@ -1569,12 +1321,11 @@ async function resolveHostCredentials( if (credential.key) { baseHost.key = credential.key; } - if (credential.key_password || credential.keyPassword) { - baseHost.keyPassword = - credential.key_password || credential.keyPassword; + if (credential.keyPassword) { + baseHost.keyPassword = credential.keyPassword; } - if (credential.key_type || credential.keyType) { - baseHost.keyType = credential.key_type || credential.keyType; + if (credential.keyType) { + baseHost.keyType = credential.keyType; } } else { addLegacyCredentials(baseHost, host); @@ -1619,7 +1370,7 @@ function addLegacyCredentials( ): void { baseHost.password = host.password || null; baseHost.key = host.key || null; - baseHost.keyPassword = host.key_password || host.keyPassword || null; + baseHost.keyPassword = host.keyPassword || null; baseHost.keyType = host.keyType; } @@ -1627,7 +1378,7 @@ async function buildSshConfig( host: SSHHostWithCredentials, ): Promise { const base: ConnectConfig = { - host: host.ip, + host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, port: host.port, username: host.username, tryKeyboard: true, @@ -1738,7 +1489,9 @@ async function buildSshConfig( throw new Error(`Invalid SSH key format for host ${host.ip}`); } } else if (host.authType === "none") { + // no credentials needed } else if (host.authType === "opkssh") { + // handled externally } else if (host.authType === "credential") { if (host.password) { base.password = host.password; @@ -1766,18 +1519,168 @@ async function buildSshConfig( return base; } +function getPoolKey(host: SSHHostWithCredentials): string { + const socks5Key = host.useSocks5 + ? `:socks5:${host.socks5Host}:${host.socks5Port}` + : ""; + return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; +} + +function createSshFactory(host: SSHHostWithCredentials): () => Promise { + return async () => { + const config = await buildSshConfig(host); + const client = new Client(); + + const proxyConfig: SOCKS5Config | null = + host.useSocks5 && + (host.socks5Host || + (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) + ? { + useSocks5: host.useSocks5, + socks5Host: host.socks5Host, + socks5Port: host.socks5Port, + socks5Username: host.socks5Username, + socks5Password: host.socks5Password, + socks5ProxyChain: host.socks5ProxyChain, + } + : null; + + const hasJumpHosts = + host.jumpHosts && host.jumpHosts.length > 0 && host.userId; + + let jumpClient: Client | null = null; + if (hasJumpHosts) { + jumpClient = await createJumpHostChain( + host.jumpHosts!, + host.userId!, + proxyConfig, + ); + + if (!jumpClient) { + throw new Error("Failed to establish jump host chain"); + } + } else if (proxyConfig) { + try { + const proxySocket = await createSocks5Connection( + host.ip, + host.port, + proxyConfig, + ); + if (proxySocket) { + config.sock = proxySocket; + } + } catch (proxyError) { + throw new Error( + "Proxy connection failed: " + + (proxyError instanceof Error + ? proxyError.message + : "Unknown error"), + ); + } + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + client.end(); + reject(new Error("SSH connection timeout")); + }, 30000); + + client.on("ready", () => { + clearTimeout(timeout); + resolve(client); + }); + + client.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + client.on( + "keyboard-interactive", + ( + _name: string, + _instructions: string, + _instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + const totpPromptIndex = prompts.findIndex((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPromptIndex !== -1) { + const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId: host.id, + userId: host.userId!, + prompts: prompts.map((p) => ({ + prompt: p.prompt, + echo: p.echo ?? false, + })), + totpPromptIndex, + resolvedPassword: host.password, + totpAttempts: 0, + }; + + return; + } else if (host.password) { + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt)) { + return host.password || ""; + } + return ""; + }); + finish(responses); + } else { + finish(prompts.map(() => "")); + } + }, + ); + + if (jumpClient) { + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port, + (err, stream) => { + if (err) { + clearTimeout(timeout); + jumpClient!.end(); + reject( + new Error( + "Failed to forward through jump host: " + err.message, + ), + ); + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } else { + client.connect(config); + } + }); + }; +} + async function withSshConnection( host: SSHHostWithCredentials, fn: (client: Client) => Promise, ): Promise { - const client = await connectionPool.getConnection(host); - - try { - const result = await fn(client); - return result; - } finally { - connectionPool.releaseConnection(host, client); - } + const key = getPoolKey(host); + const factory = createSshFactory(host); + return withConnection(key, factory, fn); } async function collectMetrics(host: SSHHostWithCredentials): Promise<{ @@ -1861,7 +1764,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ }; try { login_stats = await collectLoginStats(client); - } catch (e) {} + } catch { + // expected + } let ports: { source: "ss" | "netstat" | "none"; @@ -1879,7 +1784,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ }; try { ports = await collectPortsMetrics(client); - } catch (e) {} + } catch { + // expected + } let firewall: { type: "iptables" | "nftables" | "none"; @@ -1907,7 +1814,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ }; try { firewall = await collectFirewallMetrics(client); - } catch (e) {} + } catch { + // expected + } const result = { cpu, @@ -1975,20 +1884,52 @@ function tcpPing( const socket = new net.Socket(); let settled = false; - const onDone = (result: boolean) => { + const finish = (result: boolean) => { if (settled) return; settled = true; + resolve(result); + }; + + const cleanup = () => { try { socket.destroy(); - } catch (error) {} - resolve(result); + } catch { + // expected + } }; socket.setTimeout(timeoutMs); - socket.once("connect", () => onDone(true)); - socket.once("timeout", () => onDone(false)); - socket.once("error", () => onDone(false)); + socket.once("connect", () => { + const dataTimeout = setTimeout(() => { + cleanup(); + finish(true); + }, 2000); + + socket.once("data", (data) => { + clearTimeout(dataTimeout); + if (data.toString().startsWith("SSH-")) { + try { + socket.end("SSH-2.0-TermixHealthCheck\r\n"); + } catch { + // expected + } + setTimeout(cleanup, 200); + } else { + cleanup(); + } + finish(true); + }); + }); + + socket.once("timeout", () => { + cleanup(); + finish(false); + }); + socket.once("error", () => { + cleanup(); + finish(false); + }); socket.connect(port, host); }); } @@ -2180,7 +2121,7 @@ app.post("/host-updated", async (req, res) => { try { const host = await fetchHostById(hostId, userId); if (host) { - connectionPool.clearHostConnections(host); + connectionPool.clearKeyConnections(getPoolKey(host)); await pollingManager.startPollingForHost(host); res.json({ message: "Host polling started" }); @@ -2806,7 +2747,9 @@ app.post("/metrics/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch { + // expected + } return res.status(408).json({ error: "TOTP session timeout" }); } @@ -2819,7 +2762,9 @@ app.post("/metrics/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch { + // expected + } return res.status(429).json({ error: "Too many TOTP attempts" }); } @@ -2907,7 +2852,9 @@ app.post("/metrics/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch { + // expected + } } res.status(401).json({ @@ -3097,6 +3044,157 @@ app.post("/metrics/unregister-viewer", async (req, res) => { } }); +/** + * @openapi + * /global-settings: + * get: + * summary: Get global monitoring defaults + * tags: + * - Stats + * responses: + * 200: + * description: Global monitoring settings. + * 403: + * description: Requires admin privileges. + */ +app.get("/global-settings", requireAdmin, async (_req, res) => { + try { + const db = getDb(); + + try { + db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); + } catch (tableError) { + statsLogger.warn("Settings table does not exist, using defaults", { + operation: "global_settings_table_check", + error: + tableError instanceof Error ? tableError.message : String(tableError), + }); + return res.json({ + statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval, + }); + } + + const statusRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'global_status_check_interval'", + ) + .get() as { value: string } | undefined; + const metricsRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'global_metrics_interval'", + ) + .get() as { value: string } | undefined; + + res.json({ + statusCheckInterval: statusRow + ? parseInt(statusRow.value, 10) + : DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: metricsRow + ? parseInt(metricsRow.value, 10) + : DEFAULT_STATS_CONFIG.metricsInterval, + }); + } catch (error) { + statsLogger.error("Failed to fetch global settings", { + operation: "global_settings_fetch_error", + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ error: "Failed to fetch global settings" }); + } +}); + +/** + * @openapi + * /global-settings: + * post: + * summary: Update global monitoring defaults + * tags: + * - Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * statusCheckInterval: + * type: integer + * metricsInterval: + * type: integer + * responses: + * 200: + * description: Settings saved. + * 400: + * description: Invalid parameters. + * 403: + * description: Requires admin privileges. + */ +app.post("/global-settings", requireAdmin, async (req, res) => { + const { statusCheckInterval, metricsInterval } = req.body; + + if ( + statusCheckInterval !== undefined && + (typeof statusCheckInterval !== "number" || + statusCheckInterval < 5 || + statusCheckInterval > 3600) + ) { + return res.status(400).json({ + error: "statusCheckInterval must be between 5 and 3600 seconds", + }); + } + if ( + metricsInterval !== undefined && + (typeof metricsInterval !== "number" || + metricsInterval < 5 || + metricsInterval > 3600) + ) { + return res + .status(400) + .json({ error: "metricsInterval must be between 5 and 3600 seconds" }); + } + + try { + const db = getDb(); + + try { + db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); + } catch (tableError) { + statsLogger.error("Settings table does not exist, cannot save settings", { + operation: "global_settings_table_check", + error: + tableError instanceof Error ? tableError.message : String(tableError), + }); + return res.status(500).json({ + error: + "Database settings table is missing. Please check database initialization.", + }); + } + + if (statusCheckInterval !== undefined) { + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)", + ) + .run(String(statusCheckInterval)); + } + if (metricsInterval !== undefined) { + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)", + ) + .run(String(metricsInterval)); + } + + res.json({ success: true }); + } catch (error) { + statsLogger.error("Failed to save global settings", { + operation: "global_settings_save_error", + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ error: "Failed to save global settings" }); + } +}); + process.on("SIGINT", () => { pollingManager.destroy(); connectionPool.destroy(); diff --git a/src/backend/ssh/ssh-connection-pool.ts b/src/backend/ssh/ssh-connection-pool.ts new file mode 100644 index 00000000..b521842b --- /dev/null +++ b/src/backend/ssh/ssh-connection-pool.ts @@ -0,0 +1,225 @@ +import { Client } from "ssh2"; +import { sshLogger } from "../utils/logger.js"; + +interface PooledConnection { + client: Client; + lastUsed: number; + inUse: boolean; + hostKey: string; +} + +class SSHConnectionPool { + private connections = new Map(); + private maxConnectionsPerHost = 3; + private cleanupInterval: NodeJS.Timeout; + + constructor() { + this.cleanupInterval = setInterval( + () => { + this.cleanup(); + }, + 2 * 60 * 1000, + ); + } + + private isConnectionHealthy(client: Client): boolean { + try { + const sock = ( + client as unknown as { + _sock?: { destroyed?: boolean; writable?: boolean }; + } + )._sock; + if (sock && (sock.destroyed || !sock.writable)) { + return false; + } + return true; + } catch { + return false; + } + } + + async getConnection( + key: string, + factory: () => Promise, + ): Promise { + let connections = this.connections.get(key) || []; + + const available = connections.find((conn) => !conn.inUse); + if (available) { + if (!this.isConnectionHealthy(available.client)) { + sshLogger.warn("Removing unhealthy connection from pool", { + operation: "pool_remove_dead", + hostKey: key, + }); + try { + available.client.end(); + } catch { + // expected + } + connections = connections.filter((c) => c !== available); + this.connections.set(key, connections); + } else { + available.inUse = true; + available.lastUsed = Date.now(); + return available.client; + } + } + + if (connections.length < this.maxConnectionsPerHost) { + const client = await factory(); + const pooled: PooledConnection = { + client, + lastUsed: Date.now(), + inUse: true, + hostKey: key, + }; + connections.push(pooled); + this.connections.set(key, connections); + + client.on("end", () => { + this.removeConnection(key, client); + }); + client.on("close", () => { + this.removeConnection(key, client); + }); + + return client; + } + + return new Promise((resolve) => { + const checkAvailable = () => { + const conns = this.connections.get(key) || []; + const avail = conns.find((conn) => !conn.inUse); + if (avail) { + if (!this.isConnectionHealthy(avail.client)) { + try { + avail.client.end(); + } catch { + // expected + } + const filtered = conns.filter((c) => c !== avail); + this.connections.set(key, filtered); + factory().then((client) => { + const pooled: PooledConnection = { + client, + lastUsed: Date.now(), + inUse: true, + hostKey: key, + }; + filtered.push(pooled); + this.connections.set(key, filtered); + client.on("end", () => this.removeConnection(key, client)); + client.on("close", () => this.removeConnection(key, client)); + resolve(client); + }); + } else { + avail.inUse = true; + avail.lastUsed = Date.now(); + resolve(avail.client); + } + } else { + setTimeout(checkAvailable, 100); + } + }; + checkAvailable(); + }); + } + + releaseConnection(key: string, client: Client): void { + const connections = this.connections.get(key) || []; + const pooled = connections.find((conn) => conn.client === client); + if (pooled) { + pooled.inUse = false; + pooled.lastUsed = Date.now(); + } + } + + private removeConnection(key: string, client: Client): void { + const connections = this.connections.get(key); + if (!connections) return; + const filtered = connections.filter((c) => c.client !== client); + if (filtered.length === 0) { + this.connections.delete(key); + } else { + this.connections.set(key, filtered); + } + } + + clearKeyConnections(key: string): void { + const connections = this.connections.get(key) || []; + for (const conn of connections) { + try { + conn.client.end(); + } catch { + // expected + } + } + this.connections.delete(key); + } + + private cleanup(): void { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; + + for (const [hostKey, connections] of this.connections.entries()) { + const activeConnections = connections.filter((conn) => { + if (!conn.inUse && now - conn.lastUsed > maxAge) { + try { + conn.client.end(); + } catch { + // expected + } + return false; + } + if (!this.isConnectionHealthy(conn.client)) { + try { + conn.client.end(); + } catch { + // expected + } + return false; + } + return true; + }); + + if (activeConnections.length === 0) { + this.connections.delete(hostKey); + } else { + this.connections.set(hostKey, activeConnections); + } + } + } + + clearAllConnections(): void { + for (const connections of this.connections.values()) { + for (const conn of connections) { + try { + conn.client.end(); + } catch { + // expected + } + } + } + this.connections.clear(); + } + + destroy(): void { + clearInterval(this.cleanupInterval); + this.clearAllConnections(); + } +} + +export const connectionPool = new SSHConnectionPool(); + +export async function withConnection( + key: string, + factory: () => Promise, + fn: (client: Client) => Promise, +): Promise { + const client = await connectionPool.getConnection(key, factory); + try { + return await fn(client); + } finally { + connectionPool.releaseConnection(key, client); + } +} diff --git a/src/backend/ssh/terminal-session-manager.ts b/src/backend/ssh/terminal-session-manager.ts new file mode 100644 index 00000000..9a127355 --- /dev/null +++ b/src/backend/ssh/terminal-session-manager.ts @@ -0,0 +1,489 @@ +import { type Client, type ClientChannel } from "ssh2"; +import { WebSocket } from "ws"; +import { sshLogger } from "../utils/logger.js"; +import { getDb } from "../database/db/index.js"; + +const MAX_BUFFER_BYTES = 512 * 1024; +const DEFAULT_TIMEOUT_MINUTES = 30; +const HEALTH_CHECK_INTERVAL_MS = 60_000; +const MAX_SESSIONS_PER_USER = 10; + +export interface TerminalSession { + id: string; + userId: string; + hostId: number; + hostName: string; + tabInstanceId?: string; + attachedTabInstanceId?: string; + + sshConn: Client | null; + sshStream: ClientChannel | null; + jumpClient: Client | null; + opksshTempFiles: { keyPath: string; certPath: string } | null; + + cols: number; + rows: number; + isConnected: boolean; + createdAt: number; + + attachedWs: WebSocket | null; + lastDetachedAt: number | null; + detachTimeout: NodeJS.Timeout | null; + + outputBuffer: string[]; + outputBufferBytes: number; +} + +class TerminalSessionManager { + private static instance: TerminalSessionManager; + private sessions = new Map(); + private healthCheckTimer: NodeJS.Timeout | null = null; + + private constructor() { + this.healthCheckTimer = setInterval( + () => this.healthCheck(), + HEALTH_CHECK_INTERVAL_MS, + ); + } + + static getInstance(): TerminalSessionManager { + if (!TerminalSessionManager.instance) { + TerminalSessionManager.instance = new TerminalSessionManager(); + } + return TerminalSessionManager.instance; + } + + createSession( + userId: string, + hostId: number, + hostName: string, + cols: number, + rows: number, + tabInstanceId?: string, + ): string { + const userSessions = this.getUserSessions(userId); + if (userSessions.length >= MAX_SESSIONS_PER_USER) { + const detached = userSessions + .filter((s) => s.attachedWs === null) + .sort( + (a, b) => + (a.lastDetachedAt ?? a.createdAt) - + (b.lastDetachedAt ?? b.createdAt), + ); + if (detached.length > 0) { + this.destroySession(detached[0].id); + } + } + + if (tabInstanceId) { + const tabSessions = userSessions.filter( + (s) => s.tabInstanceId === tabInstanceId, + ); + if (tabSessions.length > 0) { + sshLogger.warn("Tab instance already has session, destroying old", { + operation: "session_tab_duplicate_cleanup", + existingSessionId: tabSessions[0].id, + tabInstanceId, + }); + this.destroySession(tabSessions[0].id); + } + } + + const id = crypto.randomUUID(); + const session: TerminalSession = { + id, + userId, + hostId, + hostName, + tabInstanceId, + sshConn: null, + sshStream: null, + jumpClient: null, + opksshTempFiles: null, + cols, + rows, + isConnected: false, + createdAt: Date.now(), + attachedWs: null, + lastDetachedAt: null, + detachTimeout: null, + outputBuffer: [], + outputBufferBytes: 0, + }; + this.sessions.set(id, session); + + sshLogger.info("Terminal session created", { + operation: "session_created", + sessionId: id, + userId, + hostId, + }); + + return id; + } + + getSession(sessionId: string | null): TerminalSession | null { + if (!sessionId) return null; + return this.sessions.get(sessionId) ?? null; + } + + setSSHState( + sessionId: string, + conn: Client, + stream: ClientChannel, + jumpClient?: Client | null, + opksshTempFiles?: { keyPath: string; certPath: string } | null, + ): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.sshConn = conn; + session.sshStream = stream; + session.jumpClient = jumpClient ?? null; + session.opksshTempFiles = opksshTempFiles ?? null; + session.isConnected = true; + } + + attachWs( + sessionId: string, + userId: string, + ws: WebSocket, + tabInstanceId?: string, + ): TerminalSession | null { + const session = this.sessions.get(sessionId); + if (!session) { + sshLogger.warn("Session not found for attachment", { + operation: "session_attach_not_found", + sessionId, + userId, + }); + return null; + } + if (session.userId !== userId) { + sshLogger.warn("Session userId mismatch", { + operation: "session_attach_user_mismatch", + sessionId, + expectedUserId: session.userId, + providedUserId: userId, + }); + return null; + } + if (!session.isConnected) { + sshLogger.warn("Session not connected", { + operation: "session_attach_not_connected", + sessionId, + userId, + createdAt: session.createdAt, + elapsed: Date.now() - session.createdAt, + }); + return null; + } + + const isDetached = + !session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN; + const isOriginalTab = session.tabInstanceId === tabInstanceId; + + if ( + !isDetached && + !isOriginalTab && + session.tabInstanceId && + tabInstanceId + ) { + sshLogger.warn("Session actively attached to different tab instance", { + operation: "session_attach_instance_conflict", + sessionId, + sessionInstanceId: session.tabInstanceId, + providedInstanceId: tabInstanceId, + }); + try { + ws.send( + JSON.stringify({ + type: "sessionExpired", + sessionId, + message: "Session belongs to a different tab instance", + }), + ); + } catch { + /* ignore */ + } + return null; + } + + if ( + session.tabInstanceId && + tabInstanceId && + session.tabInstanceId !== tabInstanceId + ) { + sshLogger.info( + "Session attached to different tab instance (split-screen)", + { + operation: "session_attach_split_screen", + originalInstanceId: session.tabInstanceId, + newInstanceId: tabInstanceId, + sessionId, + }, + ); + } + + if (session.attachedWs && session.attachedWs !== ws) { + try { + session.attachedWs.send( + JSON.stringify({ + type: "sessionTakenOver", + sessionId, + message: "Session was attached from another tab", + }), + ); + } catch { + /* ignore */ + } + session.attachedWs = null; + } + + if (session.detachTimeout) { + clearTimeout(session.detachTimeout); + session.detachTimeout = null; + } + + session.attachedWs = ws; + session.attachedTabInstanceId = tabInstanceId; + session.lastDetachedAt = null; + + sshLogger.info("WebSocket attached to session", { + operation: "session_attach", + sessionId, + userId, + tabInstanceId, + }); + + return session; + } + + detachWs(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + if (session.detachTimeout) { + clearTimeout(session.detachTimeout); + session.detachTimeout = null; + } + + session.attachedWs = null; + session.lastDetachedAt = Date.now(); + + const timeoutMs = this.getTimeoutMs(); + + session.detachTimeout = setTimeout(() => { + sshLogger.info("Session idle timeout expired", { + operation: "session_idle_timeout", + sessionId, + userId: session.userId, + }); + this.destroySession(sessionId); + }, timeoutMs); + + sshLogger.info("WebSocket detached from session", { + operation: "session_detach", + sessionId, + userId: session.userId, + timeoutMinutes: timeoutMs / 60_000, + }); + } + + destroySession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + if (session.detachTimeout) { + clearTimeout(session.detachTimeout); + session.detachTimeout = null; + } + + if (session.sshStream) { + try { + session.sshStream.end(); + } catch { + /* ignore */ + } + session.sshStream = null; + } + + if (session.sshConn) { + try { + session.sshConn.end(); + } catch { + /* ignore */ + } + session.sshConn = null; + } + + if (session.jumpClient) { + try { + session.jumpClient.end(); + } catch { + /* ignore */ + } + session.jumpClient = null; + } + + if (session.opksshTempFiles) { + const tempFiles = session.opksshTempFiles; + session.opksshTempFiles = null; + this.cleanupOpksshFiles(tempFiles); + } + + session.isConnected = false; + session.outputBuffer = []; + session.outputBufferBytes = 0; + + this.sessions.delete(sessionId); + + sshLogger.info("Terminal session destroyed", { + operation: "session_destroyed", + sessionId, + userId: session.userId, + hostId: session.hostId, + }); + } + + getUserSessions(userId: string): TerminalSession[] { + const result: TerminalSession[] = []; + for (const session of this.sessions.values()) { + if (session.userId === userId) { + result.push(session); + } + } + return result; + } + + bufferOutput(sessionId: string, data: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.outputBuffer.push(data); + session.outputBufferBytes += data.length; + + while ( + session.outputBufferBytes > MAX_BUFFER_BYTES && + session.outputBuffer.length > 0 + ) { + const removed = session.outputBuffer.shift(); + if (removed) session.outputBufferBytes -= removed.length; + } + } + + flushBuffer(session: TerminalSession): string | null { + if (session.outputBuffer.length === 0) return null; + const data = session.outputBuffer.join(""); + session.outputBuffer = []; + session.outputBufferBytes = 0; + return data; + } + + getBuffer(session: TerminalSession): string | null { + if (session.outputBuffer.length === 0) return null; + return session.outputBuffer.join(""); + } + + private getTimeoutMs(): number { + try { + const db = getDb(); + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", + ) + .get() as { value: string } | undefined; + if (row) { + const minutes = parseInt(row.value, 10); + if (!isNaN(minutes) && minutes > 0) { + return minutes * 60_000; + } + } + } catch { + // DB not available, use default + } + return DEFAULT_TIMEOUT_MINUTES * 60_000; + } + + private healthCheck(): void { + const toDestroy: string[] = []; + const now = Date.now(); + const GRACE_PERIOD_MS = 10_000; + + for (const [id, session] of this.sessions) { + if (!session.isConnected) continue; + + if ( + session.attachedWs && + session.attachedWs.readyState === WebSocket.OPEN + ) { + continue; + } + + if (session.sshStream?.destroyed) { + const detachedDuration = session.lastDetachedAt + ? now - session.lastDetachedAt + : 0; + + if (detachedDuration > GRACE_PERIOD_MS) { + sshLogger.info( + "SSH stream destroyed during detach window, cleaning up", + { + operation: "session_health_check_stream_destroyed", + sessionId: id, + userId: session.userId, + detachedFor: detachedDuration, + }, + ); + toDestroy.push(id); + } + } + + if (!session.sshConn) { + toDestroy.push(id); + } + } + + for (const id of toDestroy) { + this.destroySession(id); + } + } + + private async cleanupOpksshFiles(tempFiles: { + keyPath: string; + certPath: string; + }): Promise { + try { + const { promises: fs } = await import("fs"); + const results = await Promise.allSettled([ + fs.unlink(tempFiles.keyPath), + fs.unlink(tempFiles.certPath), + ]); + results.forEach((result, index) => { + if (result.status === "rejected") { + sshLogger.warn("Failed to cleanup OPKSSH temp file", { + operation: "opkssh_temp_cleanup_failed", + file: index === 0 ? "keyPath" : "certPath", + }); + } + }); + } catch (error) { + sshLogger.error("Failed to cleanup OPKSSH temp files", { + operation: "opkssh_temp_cleanup_error", + error, + }); + } + } + + destroyAll(): void { + for (const id of [...this.sessions.keys()]) { + this.destroySession(id); + } + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + } +} + +export const sessionManager = TerminalSessionManager.getInstance(); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 6ade781c..40efeda8 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,10 +1,5 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; -import { - Client, - type ClientChannel, - type PseudoTtyOptions, - type ConnectConfig, -} from "ssh2"; +import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; import { parse as parseUrl } from "url"; import axios from "axios"; import { getDb } from "../database/db/index.js"; @@ -14,15 +9,21 @@ import { sshLogger, authLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { UserCrypto } from "../utils/user-crypto.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../utils/socks5-helper.js"; import { SSHAuthManager } from "./auth-manager.js"; +import type { ProxyNode } from "../../types/index.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; +import { sessionManager } from "./terminal-session-manager.js"; interface ConnectToHostData { cols: number; rows: number; hostConfig: { id: number; + instanceId?: string; ip: string; port: number; username: string; @@ -41,6 +42,11 @@ interface ConnectToHostData { socks5Username?: string; socks5Password?: string; socks5ProxyChain?: unknown; + terminalConfig?: { + keepaliveInterval?: number; + keepaliveCountMax?: number; + [key: string]: unknown; + }; }; initialPath?: string; executeCommand?: string; @@ -67,10 +73,24 @@ const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); +interface JumpHostConfig { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + [key: string]: unknown; +} + async function resolveJumpHost( hostId: number, userId: string, -): Promise { +): Promise { sshLogger.info("Resolving jump host", { operation: "terminal_jumphost_resolve", userId, @@ -111,17 +131,16 @@ async function resolveJumpHost( const credential = credentials[0]; return { ...host, - password: credential.password, - key: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - keyType: credential.key_type || credential.keyType, - authType: credential.auth_type || credential.authType, - }; + password: credential.password as string | undefined, + key: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authType: credential.authType as string | undefined, + } as JumpHostConfig; } } - return host; + return host as JumpHostConfig; } catch (error) { sshLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", @@ -135,6 +154,7 @@ async function resolveJumpHost( async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, + socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -148,17 +168,31 @@ async function createJumpHostChain( jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), ); + const totalHops = jumpHostConfigs.length; + for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { sshLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, + hopIndex: i, + totalHops, }); clients.forEach((c) => c.end()); return null; } } + let proxySocket: import("net").Socket | null = null; + if (socks5Config?.useSocks5) { + const firstHop = jumpHostConfigs[0]; + proxySocket = await createSocks5Connection( + firstHop.ip, + firstHop.port || 22, + socks5Config, + ); + } + for (let i = 0; i < jumpHostConfigs.length; i++) { const jumpHostConfig = jumpHostConfigs[i]; @@ -187,22 +221,38 @@ async function createJumpHostChain( hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, depth: i, + hopIndex: i, + totalHops, + usedProxySocket: i === 0 && !!proxySocket, }); resolve(true); }); jumpClient.on("error", (err) => { clearTimeout(timeout); - sshLogger.error(`Jump host ${i + 1} connection failed`, err, { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - }); + sshLogger.error( + `Jump host ${i + 1}/${totalHops} connection failed`, + err, + { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + hopIndex: i, + totalHops, + previousHop: + i > 0 + ? jumpHostConfigs[i - 1]?.ip + : proxySocket + ? "proxy" + : "direct", + usedProxySocket: i === 0 && !!proxySocket, + }, + ); resolve(false); }); - const connectConfig: any = { - host: jumpHostConfig.ip, + const connectConfig: Record = { + host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, @@ -239,6 +289,9 @@ async function createJumpHostChain( jumpClient.connect(connectConfig); }, ); + } else if (proxySocket) { + connectConfig.sock = proxySocket; + jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } @@ -358,8 +411,10 @@ wss.on("connection", async (ws: WebSocket, req) => { userId, }); + let currentSessionId: string | null = null; let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; + let lastJumpClient: Client | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; let totpTimeout: NodeJS.Timeout | null = null; @@ -374,7 +429,14 @@ wss.on("connection", async (ws: WebSocket, req) => { let isAwaitingAuthCredentials = false; let opksshTempFiles: { keyPath: string; certPath: string } | null = null; + const wsPingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping(); + } + }, 30000); + ws.on("close", () => { + clearInterval(wsPingInterval); sshLogger.info("Terminal WebSocket disconnected", { operation: "terminal_ws_disconnect", sessionId, @@ -388,7 +450,16 @@ wss.on("connection", async (ws: WebSocket, req) => { } } - cleanupSSH(); + if (currentSessionId) { + const session = sessionManager.getSession(currentSessionId); + if (session?.isConnected) { + sessionManager.detachWs(currentSessionId); + } else { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + } + cleanupAuthState(); }); function resetConnectionState() { @@ -455,6 +526,110 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } + case "attachSession": { + const attachData = data as { + sessionId: string; + cols: number; + rows: number; + tabInstanceId?: string; + }; + sshLogger.info("Attempting to attach session", { + operation: "terminal_attach_session", + sessionId: attachData.sessionId, + tabInstanceId: attachData.tabInstanceId, + userId, + requestedCols: attachData.cols, + requestedRows: attachData.rows, + }); + const session = sessionManager.attachWs( + attachData.sessionId, + userId, + ws, + attachData.tabInstanceId, + ); + if (session) { + sshLogger.success("Session attached successfully", { + operation: "terminal_attach_success", + sessionId: attachData.sessionId, + sessionCreatedAt: session.createdAt, + wasDetached: !!session.lastDetachedAt, + detachedDuration: session.lastDetachedAt + ? Date.now() - session.lastDetachedAt + : 0, + }); + currentSessionId = attachData.sessionId; + sshStream = session.sshStream; + sshConn = session.sshConn; + isConnecting = false; + isConnected = true; + const buffered = sessionManager.getBuffer(session); + if (buffered) { + ws.send(JSON.stringify({ type: "data", data: buffered })); + } + if ( + attachData.cols !== session.cols || + attachData.rows !== session.rows + ) { + session.sshStream?.setWindow( + attachData.rows, + attachData.cols, + attachData.rows, + attachData.cols, + ); + session.cols = attachData.cols; + session.rows = attachData.rows; + } + + ws.send( + JSON.stringify({ + type: "sessionAttached", + sessionId: attachData.sessionId, + }), + ); + ws.send( + JSON.stringify({ + type: "connected", + message: "Session reattached", + }), + ); + } else { + sshLogger.warn( + "Session attachment failed - will create new connection", + { + operation: "terminal_attach_failed", + sessionId: attachData.sessionId, + tabInstanceId: attachData.tabInstanceId, + userId, + reason: "session_not_found_or_invalid", + }, + ); + ws.send( + JSON.stringify({ + type: "sessionExpired", + sessionId: attachData.sessionId, + }), + ); + } + break; + } + + case "listSessions": { + const sessions = sessionManager.getUserSessions(userId); + ws.send( + JSON.stringify({ + type: "sessionList", + sessions: sessions.map((s) => ({ + id: s.id, + hostId: s.hostId, + hostName: s.hostName, + createdAt: s.createdAt, + lastDetachedAt: s.lastDetachedAt, + })), + }), + ); + break; + } + case "resize": { const resizeData = data as ResizeData; handleResize(resizeData); @@ -462,29 +637,37 @@ wss.on("connection", async (ws: WebSocket, req) => { } case "disconnect": - cleanupSSH(); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(); + sshConn = null; + sshStream = null; break; case "input": { const inputData = data as string; - if (sshStream) { + const inputStream = + sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; + if (inputStream) { if (inputData === "\t") { - sshStream.write(inputData); + inputStream.write(inputData); } else if ( typeof inputData === "string" && inputData.startsWith("\x1b") ) { - sshStream.write(inputData); + inputStream.write(inputData); } else { try { - sshStream.write(Buffer.from(inputData, "utf8")); + inputStream.write(Buffer.from(inputData, "utf8")); } catch (error) { sshLogger.error("Error writing input to SSH stream", error, { operation: "ssh_input_encoding", userId, dataLength: inputData.length, }); - sshStream.write(Buffer.from(inputData, "latin1")); + inputStream.write(Buffer.from(inputData, "latin1")); } } } @@ -579,7 +762,9 @@ wss.on("connection", async (ws: WebSocket, req) => { if (credentialsData.password) { credentialsData.hostConfig.password = credentialsData.password; credentialsData.hostConfig.authType = "password"; - (credentialsData.hostConfig as any).userProvidedPassword = true; + ( + credentialsData.hostConfig as Record + ).userProvidedPassword = true; } else if (credentialsData.sshKey) { credentialsData.hostConfig.key = credentialsData.sshKey; credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; @@ -587,7 +772,13 @@ wss.on("connection", async (ws: WebSocket, req) => { } isAwaitingAuthCredentials = false; - cleanupSSH(); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(); + sshConn = null; + sshStream = null; const reconnectData: ConnectToHostData = { cols: credentialsData.cols, @@ -617,8 +808,9 @@ wss.on("connection", async (ws: WebSocket, req) => { case "opkssh_start_auth": { const opksshData = data as { hostId: number }; try { - const { startOPKSSHAuth, getRequestOrigin } = - await import("./opkssh-auth.js"); + const { startOPKSSHAuth, getRequestOrigin } = await import( + "./opkssh-auth.js" + ); const db = getDb(); const hostRow = await db .select() @@ -693,7 +885,7 @@ wss.on("connection", async (ws: WebSocket, req) => { hostId: number; cols?: number; rows?: number; - hostConfig?: any; + hostConfig?: ConnectToHostData["hostConfig"]; }; resetConnectionState(); @@ -703,7 +895,13 @@ wss.on("connection", async (ws: WebSocket, req) => { rows: completedData.rows || 24, hostConfig: completedData.hostConfig || - ({ id: completedData.hostId, userId } as any), + ({ + id: completedData.hostId, + ip: "", + port: 22, + username: "", + userId, + } as ConnectToHostData["hostConfig"]), }; handleConnectToHost(reconnectConfig).catch((error) => { @@ -737,7 +935,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const { hostConfig, initialPath, executeCommand } = data; const { id, - ip, + ip: rawIp, port, username, password, @@ -747,6 +945,7 @@ wss.on("connection", async (ws: WebSocket, req) => { authType, credentialId, } = hostConfig; + const ip = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp; sshLogger.info("Resolving SSH host configuration", { operation: "terminal_host_resolve", sessionId, @@ -758,7 +957,7 @@ wss.on("connection", async (ws: WebSocket, req) => { stage: string, level: string, message: string, - details?: Record, + details?: Record, ) => { ws.send( JSON.stringify({ @@ -841,12 +1040,23 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send( JSON.stringify({ type: "error", message: "SSH connection timeout" }), ); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); } }, 120000); - let resolvedCredentials = { password, key, keyPassword, keyType, authType }; - let authMethodNotAvailable = false; + let resolvedCredentials = { + username, + password, + key, + keyPassword, + keyType, + authType, + }; + const authMethodNotAvailable = false; if (credentialId && id && hostConfig.userId) { try { const credentials = await SimpleDBOps.select( @@ -866,19 +1076,12 @@ wss.on("connection", async (ws: WebSocket, req) => { if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { + username: (credential.username as string | undefined) || username, password: credential.password as string | undefined, - key: (credential.private_key || - credential.privateKey || - credential.key) as string | undefined, - keyPassword: (credential.key_password || credential.keyPassword) as - | string - | undefined, - keyType: (credential.key_type || credential.keyType) as - | string - | undefined, - authType: (credential.auth_type || credential.authType) as - | string - | undefined, + key: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authType: credential.authType as string | undefined, }; } else { sshLogger.warn(`No credentials found for host ${id}`, { @@ -926,6 +1129,27 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog("auth", "success", `Authentication successful for ${username}`); sendLog("connected", "success", "Connection established"); + const hostDisplayName = `${username}@${ip}:${port}`; + const tabInstanceId = hostConfig.instanceId; + currentSessionId = sessionManager.createSession( + userId, + id, + hostDisplayName, + data.cols, + data.rows, + tabInstanceId, + ); + + sshLogger.info("Terminal session created after SSH ready", { + operation: "terminal_session_created", + sessionId: currentSessionId, + userId, + hostId: id, + tabInstanceId, + ip, + port, + }); + const conn = sshConn; if (!conn || isCleaningUp || !sshConn) { @@ -949,6 +1173,11 @@ wss.on("connection", async (ws: WebSocket, req) => { "SSH connection was closed before terminal could be created", }), ); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } @@ -971,6 +1200,11 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); isShellInitializing = false; + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } @@ -1000,7 +1234,11 @@ wss.on("connection", async (ws: WebSocket, req) => { "Shell creation timeout. The server may not support interactive shells or the connection was interrupted.", }), ); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); } }, 15000); @@ -1029,7 +1267,11 @@ wss.on("connection", async (ws: WebSocket, req) => { message: "Shell error: " + err.message, }), ); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } @@ -1042,32 +1284,82 @@ wss.on("connection", async (ws: WebSocket, req) => { termType: "xterm-256color", }); + if (currentSessionId) { + sessionManager.setSSHState( + currentSessionId, + sshConn!, + stream, + lastJumpClient, + opksshTempFiles, + ); + sessionManager.attachWs(currentSessionId, userId, ws); + + ws.send( + JSON.stringify({ + type: "sessionCreated", + sessionId: currentSessionId, + }), + ); + + sshLogger.info("Session ready for persistence", { + operation: "session_ready", + sessionId: currentSessionId, + userId, + hostId: id, + }); + } + + const boundSessionId = currentSessionId; + stream.on("data", (data: Buffer) => { try { const utf8String = data.toString("utf-8"); - ws.send(JSON.stringify({ type: "data", data: utf8String })); + const session = sessionManager.getSession(boundSessionId); + if (session) { + sessionManager.bufferOutput(boundSessionId!, utf8String); + + if (session.attachedWs?.readyState === WebSocket.OPEN) { + session.attachedWs.send( + JSON.stringify({ type: "data", data: utf8String }), + ); + } + } } catch (error) { sshLogger.error("Error encoding terminal data", error, { operation: "terminal_data_encoding", hostId: id, dataLength: data.length, }); - ws.send( - JSON.stringify({ - type: "data", - data: data.toString("latin1"), - }), - ); + const fallback = data.toString("latin1"); + const session = sessionManager.getSession(boundSessionId); + if (session) { + sessionManager.bufferOutput(boundSessionId!, fallback); + + if (session.attachedWs?.readyState === WebSocket.OPEN) { + session.attachedWs.send( + JSON.stringify({ type: "data", data: fallback }), + ); + } + } } }); stream.on("close", () => { - ws.send( - JSON.stringify({ - type: "disconnected", - message: "Connection lost", - }), - ); + const session = sessionManager.getSession(boundSessionId); + if (session?.attachedWs?.readyState === WebSocket.OPEN) { + session.attachedWs.send( + JSON.stringify({ + type: "disconnected", + message: "Connection lost", + }), + ); + } + if (boundSessionId) { + sessionManager.destroySession(boundSessionId); + if (currentSessionId === boundSessionId) { + currentSessionId = null; + } + } }); stream.on("error", (err: Error) => { @@ -1078,12 +1370,15 @@ wss.on("connection", async (ws: WebSocket, req) => { port, username, }); - ws.send( - JSON.stringify({ - type: "error", - message: "SSH stream error: " + err.message, - }), - ); + const session = sessionManager.getSession(boundSessionId); + if (session?.attachedWs?.readyState === WebSocket.OPEN) { + session.attachedWs.send( + JSON.stringify({ + type: "error", + message: "SSH stream error: " + err.message, + }), + ); + } }); if (initialPath && initialPath.trim() !== "") { @@ -1195,14 +1490,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } })(); - clearTimeout(connectionTimeout); - if (sshConn) { - try { - sshConn.end(); - } catch (e) {} - sshConn = null; + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; } - resetConnectionState(); + cleanupAuthState(connectionTimeout); sendLog( "auth", @@ -1231,15 +1523,12 @@ wss.on("connection", async (ws: WebSocket, req) => { "error", "Server does not support keyboard-interactive authentication", ); - clearTimeout(connectionTimeout); isAwaitingAuthCredentials = true; - if (sshConn) { - try { - sshConn.end(); - } catch (e) {} - sshConn = null; + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; } - resetConnectionState(); + cleanupAuthState(connectionTimeout); ws.send( JSON.stringify({ type: "auth_method_not_available", @@ -1256,15 +1545,12 @@ wss.on("connection", async (ws: WebSocket, req) => { !isKeyboardInteractive && !keyboardInteractiveResponded ) { - clearTimeout(connectionTimeout); isAwaitingAuthCredentials = true; - if (sshConn) { - try { - sshConn.end(); - } catch (e) {} - sshConn = null; + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; } - resetConnectionState(); + cleanupAuthState(connectionTimeout); ws.send( JSON.stringify({ type: "auth_method_not_available", @@ -1333,6 +1619,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (err.message.includes("ECONNREFUSED")) { errorMessage = "SSH error: Connection refused. The server may not be running or the port may be incorrect."; + } else if (err.message.includes("ENETUNREACH")) { + const isIPv6 = ip && ip.includes(":"); + errorMessage = isIPv6 + ? "SSH error: Network unreachable. IPv6 may not be available in this environment. If running in Docker, enable IPv6 in the Docker daemon and network configuration." + : "SSH error: Network unreachable. Check your network configuration and routing."; } else if (err.message.includes("ETIMEDOUT")) { errorMessage = "SSH error: Connection timed out. Check your network connection and server availability."; @@ -1351,7 +1642,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } ws.send(JSON.stringify({ type: "error", message: errorMessage })); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); }); sshConn.on("close", () => { @@ -1364,7 +1659,11 @@ wss.on("connection", async (ws: WebSocket, req) => { }); if (isAwaitingAuthCredentials) { - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } @@ -1378,22 +1677,30 @@ wss.on("connection", async (ws: WebSocket, req) => { isShellInitializing, hasStream: !!sshStream, }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Connection closed during shell initialization. The server may have rejected the shell request.", - }), - ); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "error", + message: + "Connection closed during shell initialization. The server may have rejected the shell request.", + }), + ); + } } else if (!sshStream) { - ws.send( - JSON.stringify({ - type: "disconnected", - message: "Connection closed", - }), - ); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Connection closed", + }), + ); + } } - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); }); const sshAuthManager = new SSHAuthManager({ @@ -1429,7 +1736,9 @@ wss.on("connection", async (ws: WebSocket, req) => { instructionsLang, prompts, finish, - resolvedCredentials as any, + resolvedCredentials as unknown as Parameters< + typeof sshAuthManager.handleKeyboardInteractive + >[5], ); isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive; @@ -1444,13 +1753,20 @@ wss.on("connection", async (ws: WebSocket, req) => { }, ); - const connectConfig: any = { + const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval; + const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax; + + const connectConfig: Record = { host: ip, port, username, tryKeyboard: true, - keepaliveInterval: 30000, - keepaliveCountMax: 3, + keepaliveInterval: + typeof hostKeepaliveInterval === "number" + ? hostKeepaliveInterval + : 30000, + keepaliveCountMax: + typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3, readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, @@ -1527,6 +1843,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }; if (resolvedCredentials.authType === "none") { + // no credentials needed } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password) { sshLogger.error( @@ -1659,58 +1976,32 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - if ( + const proxyConfig: SOCKS5Config | null = hostConfig.useSocks5 && (hostConfig.socks5Host || (hostConfig.socks5ProxyChain && - (hostConfig.socks5ProxyChain as any).length > 0)) - ) { - try { - const socks5Socket = await createSocks5Connection(ip, port, { - useSocks5: hostConfig.useSocks5, - socks5Host: hostConfig.socks5Host, - socks5Port: hostConfig.socks5Port, - socks5Username: hostConfig.socks5Username, - socks5Password: hostConfig.socks5Password, - socks5ProxyChain: hostConfig.socks5ProxyChain as any, - }); + (hostConfig.socks5ProxyChain as ProxyNode[]).length > 0)) + ? { + useSocks5: hostConfig.useSocks5, + socks5Host: hostConfig.socks5Host, + socks5Port: hostConfig.socks5Port, + socks5Username: hostConfig.socks5Username, + socks5Password: hostConfig.socks5Password, + socks5ProxyChain: hostConfig.socks5ProxyChain as ProxyNode[], + } + : null; - if (socks5Socket) { - connectConfig.sock = socks5Socket; - sshConn.connect(connectConfig); - return; - } - } catch (socks5Error) { - sshLogger.error("SOCKS5 connection failed", socks5Error, { - operation: "socks5_connect", - hostId: id, - proxyHost: hostConfig.socks5Host, - proxyPort: hostConfig.socks5Port || 1080, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - "SOCKS5 proxy connection failed: " + - (socks5Error instanceof Error - ? socks5Error.message - : "Unknown error"), - }), - ); - cleanupSSH(connectionTimeout); - return; - } - } - - if ( + const hasJumpHosts = hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0 && - hostConfig.userId - ) { + hostConfig.userId; + + if (hasJumpHosts) { try { const jumpClient = await createJumpHostChain( - hostConfig.jumpHosts, - hostConfig.userId, + hostConfig.jumpHosts!, + hostConfig.userId!, + proxyConfig, ); if (!jumpClient) { @@ -1721,9 +2012,14 @@ wss.on("connection", async (ws: WebSocket, req) => { message: "Failed to connect through jump hosts", }), ); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } + lastJumpClient = jumpClient; jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { if (err) { @@ -1740,7 +2036,11 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); jumpClient.end(); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } @@ -1748,7 +2048,8 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog( "handshake", "info", - "Starting SSH session through jump host", + "Starting SSH session through jump host" + + (proxyConfig ? " (via proxy)" : ""), ); sendLog("auth", "info", `Authenticating as ${username}`); sshLogger.info("Initiating SSH connection", { @@ -1760,6 +2061,7 @@ wss.on("connection", async (ws: WebSocket, req) => { port, username, authType: resolvedCredentials.authType, + viaProxy: !!proxyConfig, }); sshConn.connect(connectConfig); }); @@ -1774,9 +2076,57 @@ wss.on("connection", async (ws: WebSocket, req) => { message: "Failed to connect through jump hosts", }), ); - cleanupSSH(connectionTimeout); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); return; } + } else if (proxyConfig) { + try { + const proxySocket = await createSocks5Connection(ip, port, proxyConfig); + if (proxySocket) { + connectConfig.sock = proxySocket; + } + } catch (proxyError) { + sshLogger.error("Proxy connection failed", proxyError, { + operation: "proxy_connect", + hostId: id, + proxyHost: hostConfig.socks5Host, + proxyPort: hostConfig.socks5Port || 1080, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Proxy connection failed: " + + (proxyError instanceof Error + ? proxyError.message + : "Unknown error"), + }), + ); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); + return; + } + sendLog("handshake", "info", "Starting SSH session (via proxy)"); + sendLog("auth", "info", `Authenticating as ${username}`); + sshLogger.info("Initiating SSH connection", { + operation: "terminal_ssh_connect_attempt", + sessionId, + userId, + hostId: id, + ip, + port, + username, + authType: resolvedCredentials.authType, + viaProxy: true, + }); + sshConn.connect(connectConfig); } else { sendLog("handshake", "info", "Starting SSH session"); sendLog("auth", "info", `Authenticating as ${username}`); @@ -1795,33 +2145,22 @@ wss.on("connection", async (ws: WebSocket, req) => { } function handleResize(data: ResizeData) { - if (sshStream && sshStream.setWindow) { - sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); + const resizeStream = + sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; + if (resizeStream && resizeStream.setWindow) { + resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols); + const session = sessionManager.getSession(currentSessionId); + if (session) { + session.cols = data.cols; + session.rows = data.rows; + } ws.send( JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), ); } } - function cleanupSSH(timeoutId?: NodeJS.Timeout) { - if (isCleaningUp) { - return; - } - - if (isShellInitializing) { - sshLogger.warn( - "Cleanup attempted during shell initialization, deferring", - { - operation: "cleanup_deferred", - userId, - }, - ); - setTimeout(() => cleanupSSH(timeoutId), 100); - return; - } - - isCleaningUp = true; - + function cleanupAuthState(timeoutId?: NodeJS.Timeout) { if (timeoutId) { clearTimeout(timeoutId); } @@ -1836,67 +2175,14 @@ wss.on("connection", async (ws: WebSocket, req) => { warpgateAuthTimeout = null; } - if (sshStream) { - try { - sshStream.end(); - } catch (e: unknown) { - sshLogger.error( - "Error closing stream: " + - (e instanceof Error ? e.message : "Unknown error"), - ); - } - sshStream = null; - } - - if (sshConn) { - try { - sshConn.end(); - } catch (e: unknown) { - sshLogger.error( - "Error closing connection: " + - (e instanceof Error ? e.message : "Unknown error"), - ); - } - sshConn = null; - } + sshStream = null; + sshConn = null; + lastJumpClient = null; + opksshTempFiles = null; resetConnectionState(); isCleaningUp = false; isAwaitingAuthCredentials = false; - - if (opksshTempFiles) { - const tempFilesToClean = opksshTempFiles; - opksshTempFiles = null; - - (async () => { - try { - const { promises: fs } = await import("fs"); - const cleanupResults = await Promise.allSettled([ - fs.unlink(tempFilesToClean.keyPath), - fs.unlink(tempFilesToClean.certPath), - ]); - - cleanupResults.forEach((result, index) => { - if (result.status === "rejected") { - sshLogger.warn(`Failed to cleanup OPKSSH temp file`, { - operation: "opkssh_temp_cleanup_failed", - file: index === 0 ? "keyPath" : "certPath", - path: - index === 0 - ? tempFilesToClean.keyPath - : tempFilesToClean.certPath, - error: result.reason, - }); - } - }); - } catch (error) { - sshLogger.error("Failed to cleanup OPKSSH temp files", { - operation: "opkssh_temp_cleanup_error", - error, - }); - } - })(); - } } // Note: PTY-level keepalive (writing \x00 to the stream) was removed. diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 77aea0bd..8cb60afd 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -6,7 +6,7 @@ import { ChildProcess } from "child_process"; import axios from "axios"; import { getDb } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { SSHHost, TunnelConfig, @@ -16,13 +16,14 @@ import type { AuthenticatedRequest, } from "../../types/index.js"; import { CONNECTION_STATES } from "../../types/index.js"; -import { tunnelLogger, sshLogger } from "../utils/logger.js"; +import { tunnelLogger } from "../utils/logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; +import { withConnection } from "./ssh-connection-pool.js"; const app = express(); app.use( @@ -61,6 +62,10 @@ app.use( ); app.use(cookieParser()); app.use(express.json()); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -603,8 +608,9 @@ async function connectSSHTunnel( tunnelConfig.requestingUserId && tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId ) { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); + const { SharedCredentialManager } = await import( + "../utils/shared-credential-manager.js" + ); const sharedCredManager = SharedCredentialManager.getInstance(); if (tunnelConfig.sourceHostId) { @@ -655,16 +661,10 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password as string | undefined, - sshKey: (credential.private_key || - credential.privateKey || - credential.key) as string | undefined, - keyPassword: (credential.key_password || - credential.keyPassword) as string | undefined, - keyType: (credential.key_type || credential.keyType) as - | string - | undefined, - authMethod: (credential.auth_type || - credential.authType) as string, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authMethod: credential.authType as string, }; } } @@ -746,16 +746,10 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedEndpointCredentials = { password: credential.password as string | undefined, - sshKey: (credential.private_key || - credential.privateKey || - credential.key) as string | undefined, - keyPassword: (credential.key_password || credential.keyPassword) as - | string - | undefined, - keyType: (credential.key_type || credential.keyType) as - | string - | undefined, - authMethod: (credential.auth_type || credential.authType) as string, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authMethod: credential.authType as string, }; } else { tunnelLogger.warn("No endpoint credentials found in database", { @@ -802,7 +796,9 @@ async function connectSSHTunnel( try { conn.end(); - } catch (error) {} + } catch { + // expected + } activeTunnels.delete(tunnelName); @@ -996,7 +992,9 @@ async function connectSSHTunnel( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch (error) {} + } catch { + // expected + } tunnelVerifications.delete(tunnelName); } @@ -1110,7 +1108,8 @@ async function connectSSHTunnel( }); const connOptions: Record = { - host: tunnelConfig.sourceIP, + host: + tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, tryKeyboard: true, @@ -1347,16 +1346,10 @@ async function killRemoteTunnelByMarker( const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password as string | undefined, - sshKey: (credential.private_key || - credential.privateKey || - credential.key) as string | undefined, - keyPassword: (credential.key_password || credential.keyPassword) as - | string - | undefined, - keyType: (credential.key_type || credential.keyType) as - | string - | undefined, - authMethod: (credential.auth_type || credential.authType) as string, + sshKey: credential.privateKey as string | undefined, + keyPassword: credential.keyPassword as string | undefined, + keyType: credential.keyType as string | undefined, + authMethod: credential.authType as string, }; } } @@ -1369,50 +1362,6 @@ async function killRemoteTunnelByMarker( } } - const conn = new Client(); - const connOptions: Record = { - host: tunnelConfig.sourceIP, - port: tunnelConfig.sourceSSHPort, - username: tunnelConfig.sourceUsername, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 15000, - algorithms: { - kex: [ - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - ], - cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", - "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc", - "3des-cbc", - ], - hmac: [ - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - if ( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey @@ -1424,151 +1373,83 @@ async function killRemoteTunnelByMarker( callback(new Error("Invalid SSH key format")); return; } - - const cleanKey = resolvedSourceCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connOptions.privateKey = Buffer.from(cleanKey, "utf8"); - if (resolvedSourceCredentials.keyPassword) { - connOptions.passphrase = resolvedSourceCredentials.keyPassword; - } - if ( - resolvedSourceCredentials.keyType && - resolvedSourceCredentials.keyType !== "auto" - ) { - connOptions.privateKeyType = resolvedSourceCredentials.keyType; - } - } else { - connOptions.password = resolvedSourceCredentials.password; } - conn.on("ready", () => { - const tunnelType = tunnelConfig.tunnelType || "remote"; - const tunnelFlag = tunnelType === "local" ? "-L" : "-R"; - const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`; + const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`; - conn.exec(checkCmd, (_err, stream) => { - let foundProcesses = false; + const factory = async (): Promise => { + const connOptions: Record = { + host: + tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 15000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; - stream.on("data", (data) => { - const output = data.toString().trim(); - if (output) { - foundProcesses = true; - } - }); + if ( + resolvedSourceCredentials.authMethod === "key" && + resolvedSourceCredentials.sshKey + ) { + const cleanKey = resolvedSourceCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connOptions.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedSourceCredentials.keyPassword) { + connOptions.passphrase = resolvedSourceCredentials.keyPassword; + } + if ( + resolvedSourceCredentials.keyType && + resolvedSourceCredentials.keyType !== "auto" + ) { + connOptions.privateKeyType = resolvedSourceCredentials.keyType; + } + } else { + connOptions.password = resolvedSourceCredentials.password; + } - stream.on("close", () => { - if (!foundProcesses) { - tunnelLogger.warn("Remote tunnel process not found", { - operation: "tunnel_remote_not_found", - userId: tunnelConfig.sourceUserId, - hostId: tunnelConfig.sourceHostId, - tunnelName, - marker: tunnelMarker, - }); - conn.end(); - callback(); - return; - } - tunnelLogger.info("Remote tunnel process found, proceeding to kill", { - operation: "tunnel_remote_found", - userId: tunnelConfig.sourceUserId, - hostId: tunnelConfig.sourceHostId, - tunnelName, - marker: tunnelMarker, - }); - - const killCmds = [ - `pkill -TERM -f '${tunnelMarker}'`, - `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`, - `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`, - `sleep 2 && pkill -9 -f '${tunnelMarker}'`, - ]; - - let commandIndex = 0; - - function executeNextKillCommand() { - if (commandIndex >= killCmds.length) { - conn.exec(checkCmd, (_err, verifyStream) => { - let stillRunning = false; - - verifyStream.on("data", (data) => { - const output = data.toString().trim(); - if (output) { - stillRunning = true; - tunnelLogger.warn( - `Processes still running after cleanup for '${tunnelName}': ${output}`, - ); - } - }); - - verifyStream.on("close", () => { - if (stillRunning) { - tunnelLogger.warn( - `Some tunnel processes may still be running for '${tunnelName}'`, - ); - } else { - tunnelLogger.success("Remote tunnel process killed", { - operation: "tunnel_remote_killed", - userId: tunnelConfig.sourceUserId, - hostId: tunnelConfig.sourceHostId, - tunnelName, - }); - } - conn.end(); - callback(); - }); - }); - return; - } - - const killCmd = killCmds[commandIndex]; - - conn.exec(killCmd, (err, stream) => { - if (err) { - tunnelLogger.warn( - `Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`, - ); - } - - stream.on("close", () => { - commandIndex++; - executeNextKillCommand(); - }); - - stream.on("data", () => {}); - - stream.stderr.on("data", (data) => { - const output = data.toString().trim(); - if (output && !output.includes("debug1")) { - tunnelLogger.warn( - `Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`, - ); - } - }); - }); - } - - executeNextKillCommand(); - }); - }); - }); - - conn.on("error", (err) => { - tunnelLogger.error( - `Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`, - ); - callback(err); - }); - - if ( - tunnelConfig.useSocks5 && - (tunnelConfig.socks5Host || - (tunnelConfig.socks5ProxyChain && - tunnelConfig.socks5ProxyChain.length > 0)) - ) { - (async () => { + if ( + tunnelConfig.useSocks5 && + (tunnelConfig.socks5Host || + (tunnelConfig.socks5ProxyChain && + tunnelConfig.socks5ProxyChain.length > 0)) + ) { try { const socks5Socket = await createSocks5Connection( tunnelConfig.sourceIP, @@ -1585,9 +1466,8 @@ async function killRemoteTunnelByMarker( if (socks5Socket) { connOptions.sock = socks5Socket; - conn.connect(connOptions); } else { - callback(new Error("Failed to create SOCKS5 connection")); + throw new Error("Failed to create SOCKS5 connection"); } } catch (socks5Error) { tunnelLogger.error( @@ -1600,18 +1480,109 @@ async function killRemoteTunnelByMarker( proxyPort: tunnelConfig.socks5Port || 1080, }, ); - callback( - new Error( - "SOCKS5 proxy connection failed: " + - (socks5Error instanceof Error - ? socks5Error.message - : "Unknown error"), - ), + throw new Error( + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), ); } - })(); - } else { - conn.connect(connOptions); + } + + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on("ready", () => resolve(conn)); + conn.on("error", (err) => reject(err)); + conn.connect(connOptions); + }); + }; + + const execCommand = (client: Client, cmd: string): Promise => + new Promise((resolve, reject) => { + client.exec(cmd, (err, stream) => { + if (err) { + reject(err); + return; + } + let output = ""; + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + stream.stderr.on("data", (data: Buffer) => { + const stderr = data.toString().trim(); + if (stderr && !stderr.includes("debug1")) { + tunnelLogger.warn( + `Kill command stderr for '${tunnelName}': ${stderr}`, + ); + } + }); + stream.on("close", () => resolve(output.trim())); + }); + }); + + try { + await withConnection(poolKey, factory, async (client) => { + const tunnelType = tunnelConfig.tunnelType || "remote"; + const tunnelFlag = tunnelType === "local" ? "-L" : "-R"; + const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`; + + const checkOutput = await execCommand(client, checkCmd); + if (!checkOutput) { + tunnelLogger.warn("Remote tunnel process not found", { + operation: "tunnel_remote_not_found", + userId: tunnelConfig.sourceUserId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + marker: tunnelMarker, + }); + return; + } + + tunnelLogger.info("Remote tunnel process found, proceeding to kill", { + operation: "tunnel_remote_found", + userId: tunnelConfig.sourceUserId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + marker: tunnelMarker, + }); + + const killCmds = [ + `pkill -TERM -f '${tunnelMarker}'`, + `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`, + `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`, + `sleep 2 && pkill -9 -f '${tunnelMarker}'`, + ]; + + for (const killCmd of killCmds) { + try { + await execCommand(client, killCmd); + } catch (err) { + tunnelLogger.warn( + `Kill command failed for '${tunnelName}': ${(err as Error).message}`, + ); + } + } + + const verifyOutput = await execCommand(client, checkCmd); + if (verifyOutput) { + tunnelLogger.warn( + `Some tunnel processes may still be running for '${tunnelName}'`, + ); + } else { + tunnelLogger.success("Remote tunnel process killed", { + operation: "tunnel_remote_killed", + userId: tunnelConfig.sourceUserId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + }); + } + }); + callback(); + } catch (err) { + tunnelLogger.error( + `Failed to connect to source host for killing tunnel '${tunnelName}': ${(err as Error).message}`, + ); + callback(err as Error); } } @@ -1750,7 +1721,7 @@ app.post( if (pendingTunnelOperations.has(tunnelName)) { try { await pendingTunnelOperations.get(tunnelName); - } catch (error) { + } catch { tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName }); } } diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/ssh/widgets/common-utils.ts index 5394ba56..66361aed 100644 --- a/src/backend/ssh/widgets/common-utils.ts +++ b/src/backend/ssh/widgets/common-utils.ts @@ -1,4 +1,4 @@ -import type { Client } from "ssh2"; +import type { Client, ClientChannel } from "ssh2"; export function execCommand( client: Client, @@ -11,7 +11,7 @@ export function execCommand( }> { return new Promise((resolve, reject) => { let settled = false; - let stream: any = null; + let stream: ClientChannel | null = null; const timeout = setTimeout(() => { if (!settled) { @@ -30,8 +30,8 @@ export function execCommand( stream.stderr.removeAllListeners(); } stream.destroy(); - } catch (error) { - // Ignore cleanup errors + } catch { + // expected - cleanup errors ignored } } }; diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/ssh/widgets/cpu-collector.ts index 90eb579b..2328f0b3 100644 --- a/src/backend/ssh/widgets/cpu-collector.ts +++ b/src/backend/ssh/widgets/cpu-collector.ts @@ -77,7 +77,7 @@ export async function collectCpuMetrics(client: Client): Promise<{ const coresNum = Number((coresOut.stdout || "").trim()); cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; - } catch (e) { + } catch { cpuPercent = null; cores = null; loadTriplet = null; diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/ssh/widgets/disk-collector.ts index b221cee2..79ae76c2 100644 --- a/src/backend/ssh/widgets/disk-collector.ts +++ b/src/backend/ssh/widgets/disk-collector.ts @@ -51,7 +51,7 @@ export async function collectDiskMetrics(client: Client): Promise<{ ); } } - } catch (e) { + } catch { diskPercent = null; usedHuman = null; totalHuman = null; diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts index a3894e74..ccb2befe 100644 --- a/src/backend/ssh/widgets/login-stats-collector.ts +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -1,6 +1,5 @@ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; -import { statsLogger } from "../../utils/logger.js"; export interface LoginRecord { user: string; @@ -53,7 +52,7 @@ export async function collectLoginStats(client: Client): Promise { parsedTime = isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); - } catch (e) { + } catch { parsedTime = new Date().toISOString(); } @@ -70,7 +69,9 @@ export async function collectLoginStats(client: Client): Promise { } } } - } catch (e) {} + } catch { + // expected + } try { const failedOut = await execCommand( @@ -111,7 +112,7 @@ export async function collectLoginStats(client: Client): Promise { parsedTime = isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); - } catch (e) { + } catch { parsedTime = new Date().toISOString(); } @@ -126,7 +127,9 @@ export async function collectLoginStats(client: Client): Promise { } } } - } catch (e) {} + } catch { + // expected + } return { recentLogins: recentLogins.slice(0, 10), diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/ssh/widgets/memory-collector.ts index 3dce5c64..e25652f5 100644 --- a/src/backend/ssh/widgets/memory-collector.ts +++ b/src/backend/ssh/widgets/memory-collector.ts @@ -27,7 +27,7 @@ export async function collectMemoryMetrics(client: Client): Promise<{ usedGiB = kibToGiB(usedKb); totalGiB = kibToGiB(totalKb); } - } catch (e) { + } catch { memPercent = null; usedGiB = null; totalGiB = null; diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts index c24b75e6..831c3883 100644 --- a/src/backend/ssh/widgets/network-collector.ts +++ b/src/backend/ssh/widgets/network-collector.ts @@ -1,6 +1,5 @@ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; -import { statsLogger } from "../../utils/logger.js"; export async function collectNetworkMetrics(client: Client): Promise<{ interfaces: Array<{ @@ -68,7 +67,9 @@ export async function collectNetworkMetrics(client: Client): Promise<{ txBytes: null, }); } - } catch (e) {} + } catch { + // expected + } return { interfaces }; } diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts index 09d62612..cde0626f 100644 --- a/src/backend/ssh/widgets/processes-collector.ts +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -1,6 +1,5 @@ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; -import { statsLogger } from "../../utils/logger.js"; export async function collectProcessesMetrics(client: Client): Promise<{ total: number | null; @@ -54,7 +53,9 @@ export async function collectProcessesMetrics(client: Client): Promise<{ const runningCount2 = Number(runningCount.stdout.trim()); runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null; - } catch (e) {} + } catch { + // expected + } return { total: totalProcesses, diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts index c5007d55..4e20ae4a 100644 --- a/src/backend/ssh/widgets/system-collector.ts +++ b/src/backend/ssh/widgets/system-collector.ts @@ -1,6 +1,5 @@ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; -import { statsLogger } from "../../utils/logger.js"; export async function collectSystemMetrics(client: Client): Promise<{ hostname: string | null; @@ -22,8 +21,8 @@ export async function collectSystemMetrics(client: Client): Promise<{ hostname = hostnameOut.stdout.trim() || null; kernel = kernelOut.stdout.trim() || null; os = osOut.stdout.trim() || null; - } catch (e) { - // No error log + } catch { + // expected } return { diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts index 3571b8a0..6349f7da 100644 --- a/src/backend/ssh/widgets/uptime-collector.ts +++ b/src/backend/ssh/widgets/uptime-collector.ts @@ -1,6 +1,5 @@ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; -import { statsLogger } from "../../utils/logger.js"; export async function collectUptimeMetrics(client: Client): Promise<{ seconds: number | null; @@ -21,7 +20,9 @@ export async function collectUptimeMetrics(client: Client): Promise<{ uptimeFormatted = `${days}d ${hours}h ${minutes}m`; } } - } catch (e) {} + } catch { + // expected + } return { seconds: uptimeSeconds, diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 215e11dd..dc102a3f 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -22,7 +22,9 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; if (persistentConfig.parsed) { Object.assign(process.env, persistentConfig.parsed); } - } catch (error) {} + } catch { + // expected - env file may not exist + } systemLogger.info("Termix backend initialization started", { operation: "backend_init_start", @@ -110,8 +112,9 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; await authManager.initialize(); DataCrypto.initialize(); - const { OPKSSHBinaryManager } = - await import("./utils/opkssh-binary-manager.js"); + const { OPKSSHBinaryManager } = await import( + "./utils/opkssh-binary-manager.js" + ); try { await OPKSSHBinaryManager.ensureBinary(); } catch (error) { @@ -163,6 +166,16 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; process.exit(0); }); + process.on("message", (msg: { type?: string }) => { + if (msg?.type === "shutdown") { + systemLogger.info( + "Received IPC shutdown, initiating graceful shutdown...", + { operation: "shutdown" }, + ); + process.exit(0); + } + }); + process.on("uncaughtException", (error) => { systemLogger.error("Uncaught exception occurred", error, { operation: "error_handling", diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 7a8c0d55..b22ff6a7 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -5,7 +5,7 @@ import { DataCrypto } from "./data-crypto.js"; import { databaseLogger, authLogger } from "./logger.js"; import type { Request, Response, NextFunction } from "express"; import { db } from "../database/db/index.js"; -import { sessions } from "../database/db/schema.js"; +import { sessions, trustedDevices } from "../database/db/schema.js"; import { eq, and, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import type { DeviceType } from "./user-agent-parser.js"; @@ -99,7 +99,7 @@ class AuthManager { const sessionDurationMs = deviceType === "desktop" || deviceType === "mobile" ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + : 2 * 60 * 60 * 1000; const authenticated = await this.userCrypto.authenticateOIDCUser( userId, @@ -121,7 +121,7 @@ class AuthManager { const sessionDurationMs = deviceType === "desktop" || deviceType === "mobile" ? 30 * 24 * 60 * 60 * 1000 - : 7 * 24 * 60 * 60 * 1000; + : 2 * 60 * 60 * 1000; const authenticated = await this.userCrypto.authenticateUser( userId, @@ -154,8 +154,9 @@ class AuthManager { return; } - const { getSqlite, saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { getSqlite, saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); const sqlite = getSqlite(); @@ -170,8 +171,9 @@ class AuthManager { } try { - const { CredentialSystemEncryptionMigration } = - await import("./credential-system-encryption-migration.js"); + const { CredentialSystemEncryptionMigration } = await import( + "./credential-system-encryption-migration.js" + ); const credMigration = new CredentialSystemEncryptionMigration(); const credResult = await credMigration.migrateUserCredentials(userId); @@ -199,6 +201,7 @@ class AuthManager { options: { expiresIn?: string; pendingTOTP?: boolean; + rememberMe?: boolean; deviceType?: DeviceType; deviceInfo?: string; } = {}, @@ -207,13 +210,13 @@ class AuthManager { let expiresIn = options.expiresIn; if (!expiresIn && !options.pendingTOTP) { - if (options.deviceType === "desktop" || options.deviceType === "mobile") { + if (options.rememberMe) { expiresIn = "30d"; } else { - expiresIn = "7d"; + expiresIn = "2h"; } } else if (!expiresIn) { - expiresIn = "7d"; + expiresIn = "2h"; } const payload: JWTPayload = { userId }; @@ -247,8 +250,9 @@ class AuthManager { }); try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -276,7 +280,7 @@ class AuthManager { private parseExpiresIn(expiresIn: string): number { const match = expiresIn.match(/^(\d+)([smhd])$/); - if (!match) return 7 * 24 * 60 * 60 * 1000; + if (!match) return 2 * 60 * 60 * 1000; const value = parseInt(match[1]); const unit = match[2]; @@ -291,7 +295,7 @@ class AuthManager { case "d": return value * 24 * 60 * 60 * 1000; default: - return 7 * 24 * 60 * 60 * 1000; + return 2 * 60 * 60 * 1000; } } @@ -340,9 +344,15 @@ class AuthManager { } } - invalidateJWTToken(token: string): void {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + invalidateJWTToken(_token: string): void { + // expected - no-op, JWT tokens are stateless + } - invalidateUserTokens(userId: string): void {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + invalidateUserTokens(_userId: string): void { + // expected - no-op, handled by session management + } async revokeSession(sessionId: string): Promise { try { @@ -354,8 +364,9 @@ class AuthManager { await db.delete(sessions).where(eq(sessions.id, sessionId)); try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -412,8 +423,9 @@ class AuthManager { } try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -454,8 +466,9 @@ class AuthManager { .where(sql`${sessions.expiresAt} < datetime('now')`); try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -488,7 +501,7 @@ class AuthManager { } } - async getAllSessions(): Promise { + async getAllSessions(): Promise[]> { try { const allSessions = await db.select().from(sessions); return allSessions; @@ -500,7 +513,7 @@ class AuthManager { } } - async getUserSessions(userId: string): Promise { + async getUserSessions(userId: string): Promise[]> { try { const userSessions = await db .select() @@ -518,7 +531,7 @@ class AuthManager { getSecureCookieOptions( req: RequestWithHeaders, - maxAge: number = 7 * 24 * 60 * 60 * 1000, + maxAge: number = 2 * 60 * 60 * 1000, ) { return { httpOnly: false, @@ -600,8 +613,9 @@ class AuthManager { .where(eq(sessions.id, payload.sessionId)) .then(async () => { try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); const remainingSessions = await db @@ -711,7 +725,7 @@ class AuthManager { .from(users) .where(eq(users.id, payload.userId)); - if (!user || user.length === 0 || !user[0].is_admin) { + if (!user || user.length === 0 || !user[0].isAdmin) { databaseLogger.warn( "Non-admin user attempted to access admin endpoint", { @@ -745,8 +759,9 @@ class AuthManager { await db.delete(sessions).where(eq(sessions.id, sessionId)); try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -768,6 +783,7 @@ class AuthManager { if (remainingSessions.length === 0) { this.userCrypto.logoutUser(userId); } else { + // expected - other sessions still active, keep user crypto state } } catch (error) { databaseLogger.error("Failed to delete session on logout", error, { @@ -810,6 +826,121 @@ class AuthManager { newPassword, ); } + + /** + * Check if device is trusted for TOTP bypass + */ + async isTrustedDevice( + userId: string, + deviceFingerprint: string, + ): Promise { + try { + const device = await db + .select() + .from(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ) + .limit(1); + + if (!device || device.length === 0) { + return false; + } + + const now = new Date(); + const expiresAt = new Date(device[0].expiresAt); + + if (now > expiresAt) { + await this.removeTrustedDevice(userId, deviceFingerprint); + return false; + } + + await db + .update(trustedDevices) + .set({ lastUsedAt: now.toISOString() }) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + + return true; + } catch (error) { + authLogger.error("Failed to check trusted device", { userId, error }); + return false; + } + } + + /** + * Add device to trusted list for TOTP bypass + */ + async addTrustedDevice( + userId: string, + deviceFingerprint: string, + deviceType: string, + deviceInfo: string, + ): Promise { + const now = new Date(); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + + const existingDevice = await db + .select() + .from(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ) + .limit(1); + + if (existingDevice && existingDevice.length > 0) { + await db + .update(trustedDevices) + .set({ + expiresAt: expiresAt.toISOString(), + lastUsedAt: now.toISOString(), + }) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + } else { + await db.insert(trustedDevices).values({ + id: nanoid(), + userId, + deviceFingerprint, + deviceType, + deviceInfo, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + lastUsedAt: now.toISOString(), + }); + } + } + + /** + * Remove trusted device + */ + async removeTrustedDevice( + userId: string, + deviceFingerprint: string, + ): Promise { + await db + .delete(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + } } export { AuthManager, type AuthenticationResult, type JWTPayload }; diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts index acb85d21..e6a2a86c 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/utils/auto-ssl-setup.ts @@ -233,7 +233,9 @@ IP.3 = 0.0.0.0 let envContent = ""; try { envContent = await fs.readFile(this.ENV_FILE, "utf8"); - } catch (error) {} + } catch { + // expected - env file may not exist yet + } let updatedContent = envContent; let hasChanges = false; diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts index ffabd66a..4dd3008f 100644 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ b/src/backend/utils/credential-system-encryption-migration.ts @@ -59,9 +59,9 @@ export class CredentialSystemEncryptionMigration { ) : null; - const plainKeyPassword = cred.key_password + const plainKeyPassword = cred.keyPassword ? FieldCrypto.decryptField( - cred.key_password, + cred.keyPassword, userDEK, cred.id.toString(), "key_password", diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 37033444..f129b443 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -131,12 +131,12 @@ class DataCrypto { db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, - updatedRecord.key_password || updatedRecord.keyPassword || null, - updatedRecord.keyType || null, - updatedRecord.autostartPassword || null, - updatedRecord.autostartKey || null, - updatedRecord.autostartKeyPassword || null, - updatedRecord.sudoPassword || null, + updatedRecord.key_password || null, + updatedRecord.key_type || null, + updatedRecord.autostart_password || null, + updatedRecord.autostart_key || null, + updatedRecord.autostart_key_password || null, + updatedRecord.sudo_password || null, record.id, ); @@ -171,10 +171,10 @@ class DataCrypto { db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, - updatedRecord.key_password || updatedRecord.keyPassword || null, - updatedRecord.private_key || updatedRecord.privateKey || null, - updatedRecord.public_key || updatedRecord.publicKey || null, - updatedRecord.keyType || null, + updatedRecord.key_password || null, + updatedRecord.private_key || null, + updatedRecord.public_key || null, + updatedRecord.key_type || null, record.id, ); @@ -207,14 +207,10 @@ class DataCrypto { WHERE id = ? `; db.prepare(updateQuery).run( - updatedRecord.totp_secret || updatedRecord.totpSecret || null, - updatedRecord.totp_backup_codes || - updatedRecord.totpBackupCodes || - null, - updatedRecord.client_secret || updatedRecord.clientSecret || null, - updatedRecord.oidc_identifier || - updatedRecord.oidcIdentifier || - null, + updatedRecord.totp_secret || null, + updatedRecord.totp_backup_codes || null, + updatedRecord.client_secret || null, + updatedRecord.oidc_identifier || null, userId, ); @@ -267,46 +263,41 @@ class DataCrypto { "password", "key", "key_password", - "keyPassword", - "keyType", - "autostartPassword", - "autostartKey", - "autostartKeyPassword", + "sudo_password", + "autostart_password", + "autostart_key", + "autostart_key_password", ], }, { table: "ssh_credentials", fields: [ "password", - "private_key", - "privateKey", - "key_password", - "keyPassword", "key", + "private_key", "public_key", - "publicKey", - "keyType", + "key_password", ], }, { table: "users", fields: [ "client_secret", - "clientSecret", "totp_secret", - "totpSecret", "totp_backup_codes", - "totpBackupCodes", "oidc_identifier", - "oidcIdentifier", ], }, ]; for (const { table, fields } of tablesToReencrypt) { try { + const selectQuery = + table === "users" + ? `SELECT * FROM ${table} WHERE id = ?` + : `SELECT * FROM ${table} WHERE user_id = ?`; const records = db - .prepare(`SELECT * FROM ${table} WHERE user_id = ?`) + .prepare(selectQuery) .all(userId) as DatabaseRecord[]; for (const record of records) { @@ -359,7 +350,13 @@ class DataCrypto { (field) => updatedRecord[field] !== record[field], ); if (updateFields.length > 0) { - const updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; + const setClause = updateFields + .map((f) => `${f} = ?`) + .join(", "); + const updateQuery = + table === "users" + ? `UPDATE ${table} SET ${setClause} WHERE id = ?` + : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; const updateValues = updateFields.map( (field) => updatedRecord[field], ); @@ -507,9 +504,9 @@ class DataCrypto { ); } - if (record.key_password && typeof record.key_password === "string") { + if (record.keyPassword && typeof record.keyPassword === "string") { systemEncrypted.systemKeyPassword = FieldCrypto.encryptField( - record.key_password as string, + record.keyPassword as string, systemKey, recordId as string, "key_password", diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index a0217578..4e31a2dd 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -327,7 +327,9 @@ class DatabaseFileEncryption { fs.accessSync(envPath, fs.constants.R_OK); envFileReadable = true; } - } catch (error) {} + } catch { + // expected - env file access check may fail + } databaseLogger.error( "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", @@ -581,7 +583,9 @@ class DatabaseFileEncryption { try { fs.accessSync(envPath, fs.constants.R_OK); result.environment.envFileReadable = true; - } catch (error) {} + } catch { + // expected - env file access check may fail + } } if ( diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts index b3c2da21..08d07e43 100644 --- a/src/backend/utils/database-save-trigger.ts +++ b/src/backend/utils/database-save-trigger.ts @@ -5,12 +5,21 @@ export class DatabaseSaveTrigger { private static isInitialized = false; private static pendingSave = false; private static saveTimeout: NodeJS.Timeout | null = null; + private static _dirty = false; static initialize(saveFunction: () => Promise): void { this.saveFunction = saveFunction; this.isInitialized = true; } + static get isDirty(): boolean { + return this._dirty; + } + + static markClean(): void { + this._dirty = false; + } + static async triggerSave( reason: string = "data_modification", ): Promise { @@ -22,6 +31,8 @@ export class DatabaseSaveTrigger { return; } + this._dirty = true; + if (this.saveTimeout) { clearTimeout(this.saveTimeout); } @@ -35,6 +46,7 @@ export class DatabaseSaveTrigger { try { await this.saveFunction!(); + this._dirty = false; } catch (error) { databaseLogger.error("Database save failed", error, { operation: "db_save_trigger_failed", diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index bf524913..52527597 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -16,43 +16,29 @@ class FieldCrypto { private static readonly ENCRYPTED_FIELDS = { users: new Set([ - "password_hash", "passwordHash", - "client_secret", "clientSecret", - "totp_secret", "totpSecret", - "totp_backup_codes", "totpBackupCodes", - "oidc_identifier", "oidcIdentifier", ]), ssh_data: new Set([ "password", "key", - "key_password", "keyPassword", + "sudoPassword", "autostartPassword", "autostartKey", "autostartKeyPassword", - "sudoPassword", ]), ssh_credentials: new Set([ "password", - "private_key", "privateKey", - "key_password", "keyPassword", "key", - "public_key", "publicKey", ]), - opkssh_tokens: new Set([ - "ssh_cert", - "sshCert", - "private_key", - "privateKey", - ]), + opkssh_tokens: new Set(["sshCert", "privateKey"]), }; static encryptField( diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index ae7ee615..62d72d64 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -82,19 +82,21 @@ export class LazyFieldEncryption { legacyFieldName, ); return decrypted; - } catch (error) {} + } catch { + // expected - legacy decryption may fail, try other methods + } } const sensitiveFields = [ - "totp_secret", - "totp_backup_codes", + "totpSecret", + "totpBackupCodes", "password", "key", - "key_password", - "private_key", - "public_key", - "client_secret", - "oidc_identifier", + "keyPassword", + "privateKey", + "publicKey", + "clientSecret", + "oidcIdentifier", ]; if (sensitiveFields.includes(fieldName)) { @@ -174,7 +176,9 @@ export class LazyFieldEncryption { wasPlaintext: false, wasLegacyEncryption: true, }; - } catch (error) {} + } catch { + // expected - re-encryption may fail, return original + } } return { encrypted: fieldValue, @@ -200,7 +204,8 @@ export class LazyFieldEncryption { let needsUpdate = false; for (const fieldName of sensitiveFields) { - const fieldValue = record[fieldName]; + const column = this.propertyToColumn(fieldName); + const fieldValue = record[column] ?? record[fieldName]; if (fieldValue) { try { @@ -213,7 +218,7 @@ export class LazyFieldEncryption { ); if (wasPlaintext || wasLegacyEncryption) { - updatedRecord[fieldName] = encrypted; + updatedRecord[column] = encrypted; migratedFields.push(fieldName); needsUpdate = true; } @@ -230,22 +235,48 @@ export class LazyFieldEncryption { return { updatedRecord, migratedFields, needsUpdate }; } + private static readonly PROPERTY_TO_COLUMN: Record = { + keyPassword: "key_password", + privateKey: "private_key", + publicKey: "public_key", + sudoPassword: "sudo_password", + autostartPassword: "autostart_password", + autostartKey: "autostart_key", + autostartKeyPassword: "autostart_key_password", + totpSecret: "totp_secret", + totpBackupCodes: "totp_backup_codes", + clientSecret: "client_secret", + oidcIdentifier: "oidc_identifier", + }; + static getSensitiveFieldsForTable(tableName: string): string[] { const sensitiveFieldsMap: Record = { - ssh_data: ["password", "key", "key_password"], + ssh_data: [ + "password", + "key", + "keyPassword", + "sudoPassword", + "autostartPassword", + "autostartKey", + "autostartKeyPassword", + ], ssh_credentials: [ "password", "key", - "key_password", - "private_key", - "public_key", + "keyPassword", + "privateKey", + "publicKey", ], - users: ["totp_secret", "totp_backup_codes"], + users: ["totpSecret", "totpBackupCodes"], }; return sensitiveFieldsMap[tableName] || []; } + static propertyToColumn(propertyName: string): string { + return this.PROPERTY_TO_COLUMN[propertyName] || propertyName; + } + static fieldNeedsMigration( fieldValue: string, userKEK: Buffer, @@ -310,10 +341,11 @@ export class LazyFieldEncryption { const hostPlaintextFields: string[] = []; for (const field of sensitiveFields) { + const column = this.propertyToColumn(field); if ( - host[field] && + host[column] && this.fieldNeedsMigration( - host[field] as string, + host[column] as string, userKEK, host.id.toString(), field, @@ -344,10 +376,11 @@ export class LazyFieldEncryption { const credentialPlaintextFields: string[] = []; for (const field of sensitiveFields) { + const column = this.propertyToColumn(field); if ( - credential[field] && + credential[column] && this.fieldNeedsMigration( - credential[field] as string, + credential[column] as string, userKEK, credential.id.toString(), field, @@ -373,9 +406,10 @@ export class LazyFieldEncryption { const userPlaintextFields: string[] = []; for (const field of sensitiveFields) { + const column = this.propertyToColumn(field); if ( - user[field] && - this.fieldNeedsMigration(user[field], userKEK, userId, field) + user[column] && + this.fieldNeedsMigration(user[column], userKEK, userId, field) ) { userPlaintextFields.push(field); needsMigration = true; diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index dd05b0fa..ff1cd4ec 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -66,15 +66,14 @@ class PermissionManager { private async cleanupExpiredAccess(): Promise { try { const now = new Date().toISOString(); - const result = await db + await db .delete(hostAccess) .where( and( sql`${hostAccess.expiresAt} IS NOT NULL`, sql`${hostAccess.expiresAt} <= ${now}`, ), - ) - .returning({ id: hostAccess.id }); + ); } catch (error) { databaseLogger.error("Failed to cleanup expired host access", error, { operation: "host_access_cleanup_failed", @@ -280,7 +279,7 @@ class PermissionManager { async isAdmin(userId: string): Promise { try { const user = await db - .select({ isAdmin: users.is_admin }) + .select({ isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, userId)) .limit(1); @@ -383,7 +382,8 @@ class PermissionManager { }); } - (req as any).hostAccessInfo = accessInfo; + (req as unknown as { hostAccessInfo: HostAccessInfo }).hostAccessInfo = + accessInfo; next(); }; diff --git a/src/backend/utils/proxy-agent.ts b/src/backend/utils/proxy-agent.ts index 71a9d6e7..6e73e275 100644 --- a/src/backend/utils/proxy-agent.ts +++ b/src/backend/utils/proxy-agent.ts @@ -18,9 +18,7 @@ export function getProxyAgent(targetUrl?: string): Agent | undefined { const trimmed = entry.trim().toLowerCase(); if (!trimmed) continue; - const normalized = trimmed - .replace(/^\*\./, "") - .replace(/^\./, ""); + const normalized = trimmed.replace(/^\*\./, "").replace(/^\./, ""); if (hostname === normalized || hostname.endsWith(`.${normalized}`)) { return undefined; diff --git a/src/backend/utils/proxy-helper.ts b/src/backend/utils/proxy-helper.ts new file mode 100644 index 00000000..7c4a3aa5 --- /dev/null +++ b/src/backend/utils/proxy-helper.ts @@ -0,0 +1,340 @@ +import { SocksClient } from "socks"; +import type { SocksClientOptions } from "socks"; +import net from "net"; +import { sshLogger } from "./logger.js"; +import type { ProxyNode } from "../../types/index.js"; + +export interface SOCKS5Config { + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; +} + +export async function createProxyConnection( + targetHost: string, + targetPort: number, + socks5Config: SOCKS5Config, +): Promise { + if (!socks5Config.useSocks5) { + return null; + } + + if ( + socks5Config.socks5ProxyChain && + socks5Config.socks5ProxyChain.length > 0 + ) { + return createMixedProxyChainConnection( + targetHost, + targetPort, + socks5Config.socks5ProxyChain, + ); + } + + if (socks5Config.socks5Host) { + return createSingleProxyConnection(targetHost, targetPort, socks5Config); + } + + return null; +} + +export const createSocks5Connection = createProxyConnection; + +async function createSingleProxyConnection( + targetHost: string, + targetPort: number, + socks5Config: SOCKS5Config, +): Promise { + const socksOptions: SocksClientOptions = { + proxy: { + host: socks5Config.socks5Host!, + port: socks5Config.socks5Port || 1080, + type: 5, + userId: socks5Config.socks5Username, + password: socks5Config.socks5Password, + }, + command: "connect", + destination: { + host: targetHost, + port: targetPort, + }, + }; + + try { + const info = await SocksClient.createConnection(socksOptions); + + return info.socket; + } catch (error) { + sshLogger.error("SOCKS5 connection failed", error, { + operation: "socks5_connect_failed", + proxyHost: socks5Config.socks5Host, + proxyPort: socks5Config.socks5Port || 1080, + targetHost, + targetPort, + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } +} + +export async function createHttpConnectConnection( + targetHost: string, + targetPort: number, + proxyHost: string, + proxyPort: number, + username?: string, + password?: string, + existingSocket?: net.Socket, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + `HTTP CONNECT proxy timeout connecting to ${proxyHost}:${proxyPort}`, + ), + ); + }, 15000); + + function sendConnect(socket: net.Socket) { + let connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`; + if (username && password) { + const credentials = Buffer.from(`${username}:${password}`).toString( + "base64", + ); + connectReq += `Proxy-Authorization: Basic ${credentials}\r\n`; + } + connectReq += "\r\n"; + + let responseBuffer = ""; + + function onData(chunk: Buffer) { + responseBuffer += chunk.toString("utf8"); + const headerEnd = responseBuffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + + clearTimeout(timeout); + socket.removeListener("data", onData); + socket.removeListener("error", onError); + + const statusLine = responseBuffer.slice( + 0, + responseBuffer.indexOf("\r\n"), + ); + const statusCode = parseInt(statusLine.split(" ")[1], 10); + + if (statusCode === 200) { + resolve(socket); + } else { + socket.destroy(); + reject( + new Error( + `HTTP CONNECT proxy returned ${statusCode}: ${statusLine}`, + ), + ); + } + } + + function onError(err: Error) { + clearTimeout(timeout); + reject(new Error(`HTTP CONNECT proxy error: ${err.message}`)); + } + + socket.on("data", onData); + socket.on("error", onError); + socket.write(connectReq); + } + + if (existingSocket) { + sendConnect(existingSocket); + } else { + const socket = net.connect(proxyPort, proxyHost, () => { + sendConnect(socket); + }); + + socket.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`HTTP CONNECT proxy TCP error: ${err.message}`)); + }); + } + }); +} + +export async function createMixedProxyChainConnection( + targetHost: string, + targetPort: number, + proxyChain: ProxyNode[], +): Promise { + if (proxyChain.length === 0) { + throw new Error("Proxy chain is empty"); + } + + const hasMixedTypes = proxyChain.some((p) => p.type === "http"); + + if (!hasMixedTypes) { + return createPureSocksChainConnection(targetHost, targetPort, proxyChain); + } + + return createHopByHopConnection(targetHost, targetPort, proxyChain); +} + +async function createPureSocksChainConnection( + targetHost: string, + targetPort: number, + proxyChain: ProxyNode[], +): Promise { + try { + const info = await SocksClient.createConnectionChain({ + proxies: proxyChain.map((p) => ({ + host: p.host, + port: p.port, + type: p.type as 4 | 5, + userId: p.username, + password: p.password, + timeout: 10000, + })), + command: "connect", + destination: { + host: targetHost, + port: targetPort, + }, + }); + return info.socket; + } catch (error) { + sshLogger.error("SOCKS proxy chain connection failed", error, { + operation: "socks5_chain_connect_failed", + chainLength: proxyChain.length, + targetHost, + targetPort, + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } +} + +async function createHopByHopConnection( + targetHost: string, + targetPort: number, + proxyChain: ProxyNode[], +): Promise { + let currentSocket: net.Socket | null = null; + + try { + for (let i = 0; i < proxyChain.length; i++) { + const node = proxyChain[i]; + const isLast = i === proxyChain.length - 1; + const nextTarget = isLast + ? { host: targetHost, port: targetPort } + : { host: proxyChain[i + 1].host, port: proxyChain[i + 1].port }; + + if (node.type === "http") { + currentSocket = await createHttpConnectConnection( + nextTarget.host, + nextTarget.port, + node.host, + node.port, + node.username, + node.password, + currentSocket ?? undefined, + ); + } else { + const socksOptions: SocksClientOptions = { + proxy: { + host: node.host, + port: node.port, + type: node.type as 4 | 5, + userId: node.username, + password: node.password, + }, + command: "connect", + destination: nextTarget, + }; + if (currentSocket) { + socksOptions.existing_socket = currentSocket; + } + const info = await SocksClient.createConnection(socksOptions); + currentSocket = info.socket; + } + } + + if (!currentSocket) { + throw new Error("Proxy chain produced no socket"); + } + return currentSocket; + } catch (error) { + if (currentSocket) { + currentSocket.destroy(); + } + sshLogger.error("Mixed proxy chain connection failed", error, { + operation: "mixed_chain_connect_failed", + chainLength: proxyChain.length, + targetHost, + targetPort, + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } +} + +export async function testProxyConnectivity(options: { + singleProxy?: { + host: string; + port: number; + type?: 4 | 5 | "http"; + username?: string; + password?: string; + }; + proxyChain?: ProxyNode[]; + testTarget?: { host: string; port: number }; +}): Promise<{ success: boolean; latencyMs: number }> { + const target = options.testTarget ?? { host: "google.com", port: 443 }; + const start = Date.now(); + + let socket: net.Socket | null = null; + try { + if (options.proxyChain && options.proxyChain.length > 0) { + socket = await createMixedProxyChainConnection( + target.host, + target.port, + options.proxyChain, + ); + } else if (options.singleProxy) { + const proxy = options.singleProxy; + if (proxy.type === "http") { + socket = await createHttpConnectConnection( + target.host, + target.port, + proxy.host, + proxy.port, + proxy.username, + proxy.password, + ); + } else { + const socksOptions: SocksClientOptions = { + proxy: { + host: proxy.host, + port: proxy.port, + type: (proxy.type as 4 | 5) || 5, + userId: proxy.username, + password: proxy.password, + }, + command: "connect", + destination: target, + timeout: 10000, + }; + const info = await SocksClient.createConnection(socksOptions); + socket = info.socket; + } + } else { + throw new Error("No proxy configuration provided"); + } + + const latencyMs = Date.now() - start; + socket.destroy(); + return { success: true, latencyMs }; + } catch (error) { + if (socket) socket.destroy(); + throw error; + } +} diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts index 86d68362..d58f7eba 100644 --- a/src/backend/utils/shared-credential-manager.ts +++ b/src/backend/utils/shared-credential-manager.ts @@ -3,7 +3,6 @@ import { sharedCredentials, sshCredentials, hostAccess, - users, userRoles, sshData, } from "../database/db/schema.js"; @@ -293,10 +292,9 @@ class SharedCredentialManager { credentialId: number, ): Promise { try { - const result = await db + await db .delete(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)) - .returning({ id: sharedCredentials.id }); + .where(eq(sharedCredentials.originalCredentialId, credentialId)); } catch (error) { databaseLogger.error("Failed to delete shared credentials", error, { operation: "delete_shared_credentials", @@ -364,9 +362,9 @@ class SharedCredentialManager { key: cred.key ? this.decryptField(cred.key, ownerDEK, credentialId, "key") : undefined, - keyPassword: cred.key_password + keyPassword: cred.keyPassword ? this.decryptField( - cred.key_password, + cred.keyPassword, ownerDEK, credentialId, "key_password", @@ -534,7 +532,7 @@ class SharedCredentialManager { recordId.toString(), fieldName, ); - } catch (error) { + } catch { databaseLogger.warn("Field decryption failed, returning as-is", { operation: "decrypt_field", fieldName, diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 12fbee1b..ab435503 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -1,6 +1,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; +import type { SQL } from "drizzle-orm"; type TableName = | "users" @@ -11,7 +12,7 @@ type TableName = class SimpleDBOps { static async insert>( - table: SQLiteTable, + table: SQLiteTable, tableName: TableName, data: T, userId: string, @@ -109,7 +110,7 @@ class SimpleDBOps { } static async update>( - table: SQLiteTable, + table: SQLiteTable, tableName: TableName, where: unknown, data: Partial, @@ -141,7 +142,7 @@ class SimpleDBOps { const result = await getDb() .update(table) .set(encryptedData) - .where(where as any) + .where(where as SQL | undefined) .returning(); DatabaseSaveTrigger.triggerSave(`update_${tableName}`); @@ -157,13 +158,13 @@ class SimpleDBOps { } static async delete( - table: SQLiteTable, + table: SQLiteTable, tableName: TableName, where: unknown, ): Promise { const result = await getDb() .delete(table) - .where(where as any) + .where(where as SQL | undefined) .returning(); DatabaseSaveTrigger.triggerSave(`delete_${tableName}`); diff --git a/src/backend/utils/socks5-helper.ts b/src/backend/utils/socks5-helper.ts index 7647fd6a..4799c2f9 100644 --- a/src/backend/utils/socks5-helper.ts +++ b/src/backend/utils/socks5-helper.ts @@ -1,117 +1,8 @@ -import { SocksClient } from "socks"; -import type { SocksClientOptions } from "socks"; -import net from "net"; -import { sshLogger } from "./logger.js"; -import type { ProxyNode } from "../../types/index.js"; - -export interface SOCKS5Config { - useSocks5?: boolean; - socks5Host?: string; - socks5Port?: number; - socks5Username?: string; - socks5Password?: string; - socks5ProxyChain?: ProxyNode[]; -} - -export async function createSocks5Connection( - targetHost: string, - targetPort: number, - socks5Config: SOCKS5Config, -): Promise { - if (!socks5Config.useSocks5) { - return null; - } - - if ( - socks5Config.socks5ProxyChain && - socks5Config.socks5ProxyChain.length > 0 - ) { - return createProxyChainConnection( - targetHost, - targetPort, - socks5Config.socks5ProxyChain, - ); - } - - if (socks5Config.socks5Host) { - return createSingleProxyConnection(targetHost, targetPort, socks5Config); - } - - return null; -} - -async function createSingleProxyConnection( - targetHost: string, - targetPort: number, - socks5Config: SOCKS5Config, -): Promise { - const socksOptions: SocksClientOptions = { - proxy: { - host: socks5Config.socks5Host!, - port: socks5Config.socks5Port || 1080, - type: 5, - userId: socks5Config.socks5Username, - password: socks5Config.socks5Password, - }, - command: "connect", - destination: { - host: targetHost, - port: targetPort, - }, - }; - - try { - const info = await SocksClient.createConnection(socksOptions); - - return info.socket; - } catch (error) { - sshLogger.error("SOCKS5 connection failed", error, { - operation: "socks5_connect_failed", - proxyHost: socks5Config.socks5Host, - proxyPort: socks5Config.socks5Port || 1080, - targetHost, - targetPort, - errorMessage: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } -} - -async function createProxyChainConnection( - targetHost: string, - targetPort: number, - proxyChain: ProxyNode[], -): Promise { - if (proxyChain.length === 0) { - throw new Error("Proxy chain is empty"); - } - - const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → "); - try { - const info = await SocksClient.createConnectionChain({ - proxies: proxyChain.map((p) => ({ - host: p.host, - port: p.port, - type: p.type, - userId: p.username, - password: p.password, - timeout: 10000, - })), - command: "connect", - destination: { - host: targetHost, - port: targetPort, - }, - }); - return info.socket; - } catch (error) { - sshLogger.error("SOCKS proxy chain connection failed", error, { - operation: "socks5_chain_connect_failed", - chainLength: proxyChain.length, - targetHost, - targetPort, - errorMessage: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } -} +export { + createSocks5Connection, + createProxyConnection, + createHttpConnectConnection, + createMixedProxyChainConnection, + testProxyConnectivity, +} from "./proxy-helper.js"; +export type { SOCKS5Config } from "./proxy-helper.js"; diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 8685ec99..a4c9efc4 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -1,5 +1,4 @@ import ssh2Pkg from "ssh2"; -import { sshLogger } from "./logger.js"; const ssh2Utils = ssh2Pkg.utils; function detectKeyTypeFromContent(keyContent: string): string { @@ -85,7 +84,9 @@ function detectKeyTypeFromContent(keyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch (error) {} + } catch { + // expected - base64 decode may fail for some key formats + } if (content.length < 800) { return "ssh-ed25519"; @@ -141,7 +142,9 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch (error) {} + } catch { + // expected - base64 decode may fail for some key formats + } if (content.length < 400) { return "ssh-ed25519"; @@ -243,7 +246,9 @@ export function parseSSHKey( useSSH2 = true; } - } catch (error) {} + } catch { + // expected - ssh2 key parsing may fail + } } if (!useSSH2) { @@ -269,7 +274,9 @@ export function parseSSHKey( success: true, }; } - } catch (error) {} + } catch { + // expected - fallback key type detection may fail + } return { privateKey: privateKeyData, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 34f60cee..1d43238e 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -52,7 +52,9 @@ class SystemCrypto { }, ); } - } catch (fileError) {} + } catch { + // expected - env file may not exist + } await this.generateAndGuideUser(); } catch (error) { @@ -78,12 +80,6 @@ class SystemCrypto { const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); - const keyFingerprint = crypto - .createHash("sha256") - .update(this.databaseKey) - .digest("hex") - .substring(0, 16); - return; } @@ -93,17 +89,13 @@ class SystemCrypto { if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; - - const keyFingerprint = crypto - .createHash("sha256") - .update(this.databaseKey) - .digest("hex") - .substring(0, 16); - return; } else { + // expected - key not found or invalid length in env file } - } catch (fileError) {} + } catch { + // expected - env file may not exist + } await this.generateAndGuideDatabaseKey(); } catch (error) { @@ -141,7 +133,9 @@ class SystemCrypto { process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1]; return; } - } catch (error) {} + } catch { + // expected - env file may not exist + } await this.generateAndGuideInternalAuthToken(); } catch (error) { @@ -178,7 +172,9 @@ class SystemCrypto { process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1]; return; } - } catch (fileError) {} + } catch { + // expected - env file may not exist + } await this.generateAndGuideCredentialSharingKey(); } catch (error) { diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts index fb1a3563..91e43c0d 100644 --- a/src/backend/utils/user-agent-parser.ts +++ b/src/backend/utils/user-agent-parser.ts @@ -1,4 +1,5 @@ import type { Request } from "express"; +import crypto from "crypto"; export type DeviceType = "web" | "desktop" | "mobile"; @@ -237,3 +238,22 @@ function parseMacVersion(userAgent: string): string { } return "macOS"; } + +/** + * Generate a stable device fingerprint based on device type, browser, and OS. + * Ignores minor version numbers to handle browser auto-updates. + */ +export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string { + let fingerprintString = ""; + + if (deviceInfo.type === "desktop") { + fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; + } else if (deviceInfo.type === "mobile") { + fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; + } else { + const browserMajor = deviceInfo.version.split(".")[0]; + fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`; + } + + return crypto.createHash("sha256").update(fingerprintString).digest("hex"); +} diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index b536bcf7..063cfbe1 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -303,8 +303,9 @@ class UserCrypto { await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); oldKEK.fill(0); @@ -340,8 +341,9 @@ class UserCrypto { await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); newKEK.fill(0); @@ -416,8 +418,9 @@ class UserCrypto { }, ); - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); await saveMemoryDatabaseToFile(); } catch (error) { databaseLogger.error("Failed to convert to OIDC encryption", error, { diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts index 956f7ce6..31783e35 100644 --- a/src/backend/utils/user-data-import.ts +++ b/src/backend/utils/user-data-import.ts @@ -194,7 +194,7 @@ class UserDataImport { continue; } - const newHostData: any = { + const newHostData: Record = { ...host, userId: targetUserId, updatedAt: new Date().toISOString(), @@ -204,7 +204,7 @@ class UserDataImport { newHostData.createdAt = new Date().toISOString(); } - let processedHostData: any = newHostData; + let processedHostData: Record = newHostData; if (options.userDataKey) { processedHostData = DataCrypto.encryptRecord( "ssh_data", @@ -275,7 +275,7 @@ class UserDataImport { continue; } - const newCredentialData: any = { + const newCredentialData: Record = { ...credential, userId: targetUserId, updatedAt: new Date().toISOString(), @@ -287,7 +287,8 @@ class UserDataImport { newCredentialData.createdAt = new Date().toISOString(); } - let processedCredentialData: any = newCredentialData; + let processedCredentialData: Record = + newCredentialData; if (options.userDataKey) { processedCredentialData = DataCrypto.encryptRecord( "ssh_credentials", diff --git a/src/constants/terminal-themes.ts b/src/constants/terminal-themes.ts index d9736ea9..e7235c9f 100644 --- a/src/constants/terminal-themes.ts +++ b/src/constants/terminal-themes.ts @@ -762,6 +762,8 @@ export const DEFAULT_TERMINAL_CONFIG = { autoMosh: false, moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8", sudoPasswordAutoFill: false, + keepaliveInterval: undefined as number | undefined, + keepaliveCountMax: undefined as number | undefined, }; export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG; diff --git a/src/lib/clipboard-provider.ts b/src/lib/clipboard-provider.ts new file mode 100644 index 00000000..9254fbe2 --- /dev/null +++ b/src/lib/clipboard-provider.ts @@ -0,0 +1,47 @@ +import type { + IClipboardProvider, + ClipboardSelectionType, +} from "@xterm/addon-clipboard"; + +export class RobustClipboardProvider implements IClipboardProvider { + private pendingWrite: string | null = null; + private readonly focusHandler: () => void; + + constructor() { + this.focusHandler = () => { + if (this.pendingWrite !== null) { + const text = this.pendingWrite; + this.pendingWrite = null; + navigator.clipboard.writeText(text).catch(() => { + this.pendingWrite = text; + }); + } + }; + window.addEventListener("focus", this.focusHandler); + } + + dispose(): void { + window.removeEventListener("focus", this.focusHandler); + this.pendingWrite = null; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + readText(selection: ClipboardSelectionType): string { + return ""; + } + + async writeText( + selection: ClipboardSelectionType, + text: string, + ): Promise { + try { + if (window.electronClipboard) { + window.electronClipboard.writeText(text); + return; + } + await navigator.clipboard.writeText(text); + } catch { + this.pendingWrite = text; + } + } +} diff --git a/src/lib/terminal-syntax-highlighter.ts b/src/lib/terminal-syntax-highlighter.ts index b9aef563..b9195670 100644 --- a/src/lib/terminal-syntax-highlighter.ts +++ b/src/lib/terminal-syntax-highlighter.ts @@ -113,14 +113,14 @@ const PATTERNS: HighlightPattern[] = [ function hasExistingAnsiCodes(text: string): boolean { const ansiCount = ( text.match( - /\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g, + /\x1b[[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g, ) || [] ).length; return ansiCount > MAX_ANSI_CODES; } function hasIncompleteAnsiSequence(text: string): boolean { - return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text); + return /\x1b(?:\[(?:[0-9;?>=!]*)?)?$/.test(text); } interface TextSegment { @@ -130,7 +130,7 @@ interface TextSegment { function parseAnsiSegments(text: string): TextSegment[] { const segments: TextSegment[] = []; - const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g; + const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g; let lastIndex = 0; let match; diff --git a/src/locales/en.json b/src/locales/en.json index 856d8d4a..b5058ffc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -331,6 +331,8 @@ "twoSplit": "2-Way", "threeSplit": "3-Way", "fourSplit": "4-Way", + "fiveSplit": "5-Way", + "sixSplit": "6-Way", "availableTabs": "Available Tabs", "dragTabsHint": "Drag tabs to the layout cells below to assign them", "layout": "Split Screen Layout", @@ -372,7 +374,11 @@ "warning": "Warning", "notValidatedWarning": "URL not validated - ensure it's correct", "changeServer": "Change Server", - "mustIncludeProtocol": "Server URL must start with http:// or https://" + "mustIncludeProtocol": "Server URL must start with http:// or https://", + "useEmbedded": "Use Local Server", + "embeddedDesc": "Run Termix with the built-in local server (no remote server needed)", + "embeddedConnecting": "Connecting to local server...", + "embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again." }, "versionCheck": { "error": "Version Check Error", @@ -471,6 +477,7 @@ "confirm": "Confirm", "yes": "Yes", "no": "No", + "or": "OR", "ok": "OK", "enabled": "Enabled", "disabled": "Disabled", @@ -601,6 +608,8 @@ "userDeletedSuccessfully": "User {{username}} deleted successfully", "failedToDeleteUser": "Failed to delete user", "overrideUserInfoUrl": "Override User Info URL (not required)", + "allowedUsers": "Allowed Users", + "allowedUsersDescription": "Comma-separated list of allowed email patterns. Use exact emails (user@example.com), domain suffixes (@example.com), or leave empty to allow all users.", "failedToFetchSessions": "Failed to fetch sessions", "sessionRevokedSuccessfully": "Session revoked successfully", "failedToRevokeSession": "Failed to revoke session", @@ -794,6 +803,13 @@ "oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.", "confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?", "failedToUpdatePasswordLoginStatus": "Failed to update password login status", + "monitoringDefaults": "Monitoring Defaults", + "monitoringDefaultsDesc": "Global default intervals for status checks and metrics collection. Per-host settings override these values.", + "globalStatusCheckInterval": "Default Status Check Interval", + "globalMetricsInterval": "Default Metrics Collection Interval", + "globalSettingsSaved": "Global monitoring settings saved", + "failedToSaveGlobalSettings": "Failed to save global monitoring settings", + "failedToLoadGlobalSettings": "Failed to load global monitoring settings", "sessionManagement": "Session Management", "loadingSessions": "Loading sessions...", "noActiveSessions": "No active sessions found.", @@ -849,6 +865,11 @@ "noHostsInJson": "No hosts found in JSON file", "maxHostsAllowed": "Maximum 100 hosts allowed per import", "importCompleted": "Import completed: {{success}} successful, {{failed}} failed", + "importCreated": "created", + "importUpdated": "updated", + "importFailedCount": "failed", + "importSkipExisting": "Import (skip existing)", + "importOverwriteExisting": "Import (overwrite existing)", "importFailed": "Import failed", "importError": "Import error", "failedToImportJson": "Failed to import JSON file", @@ -1027,6 +1048,9 @@ "statusCheckEnabledDesc": "Check if the server is online or offline", "statusCheckInterval": "Status Check Interval", "statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)", + "useGlobalStatusInterval": "Use global default", + "useGlobalMetricsInterval": "Use global default", + "usingGlobalDefault": "Using global default ({{value}}s)", "metricsEnabled": "Enable Metrics Monitoring", "metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics", "metricsInterval": "Metrics Collection Interval", @@ -1184,8 +1208,20 @@ "sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password", "sudoPassword": "Sudo Password", "sudoPasswordDesc": "Optional password for sudo commands (useful with key authentication)", + "keepaliveSettings": "SSH Keepalive", + "keepaliveSettingsDesc": "Configure SSH-level keepalive behavior. Set interval to 0 to disable SSH keepalives (useful for MikroTik and other devices that ignore keepalive requests).", + "keepaliveInterval": "Keepalive Interval (ms)", + "keepaliveIntervalDesc": "How often to send SSH keepalive pings. Default: 30000 (30s). Set to 0 to disable.", + "keepaliveCountMax": "Keepalive Max Failures", + "keepaliveCountMaxDesc": "Disconnect after this many unanswered keepalives. Default: 3.", "socks4": "SOCKS4", "socks5": "SOCKS5", + "httpConnect": "HTTP CONNECT", + "testProxy": "Test Connection", + "testingProxy": "Testing...", + "proxyTestSuccess": "Proxy connection successful ({{latency}}ms)", + "proxyTestFailed": "Proxy test failed: {{error}}", + "connectionPath": "Connection Path", "executeSnippetOnConnect": "Execute a snippet when the terminal connects", "autoMosh": "Auto-MOSH", "autoMoshDesc": "Automatically run MOSH command on connect", @@ -1296,7 +1332,35 @@ "showTimestamps": "Show Timestamps", "autoRefresh": "Auto Refresh", "filterLogsPlaceholder": "Filter logs...", - "noLogsAvailable": "No logs available" + "noLogsAvailable": "No logs available", + "selectMode": "Select", + "exitSelectMode": "Cancel", + "selectedCount": "{{count}} selected", + "bulkMonitoring": "Monitoring", + "bulkFeatures": "Features", + "bulkMoveFolder": "Move to Folder", + "bulkPin": "Pin", + "bulkUnpin": "Unpin", + "enableAllFeatures": "Enable All Features", + "disableAllFeatures": "Disable All Features", + "enableStatusCheck": "Enable Status Check", + "disableStatusCheck": "Disable Status Check", + "enableMetrics": "Enable Metrics", + "disableMetrics": "Disable Metrics", + "bulkEnableTerminal": "Enable Terminal", + "bulkDisableTerminal": "Disable Terminal", + "bulkEnableTunnel": "Enable Tunnel", + "bulkDisableTunnel": "Disable Tunnel", + "bulkEnableFileManager": "Enable File Manager", + "bulkDisableFileManager": "Disable File Manager", + "bulkEnableDocker": "Enable Docker", + "bulkDisableDocker": "Disable Docker", + "bulkUpdateSuccess": "Updated {{count}} host(s) successfully", + "bulkUpdateFailed": "Bulk update failed", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "useGlobalStatusDefault": "Use Global Default (Status)", + "useGlobalMetricsDefault": "Use Global Default (Metrics)" }, "terminal": { "title": "Split Screen", @@ -1304,6 +1368,8 @@ "twoSplit": "2-Split", "threeSplit": "3-Split", "fourSplit": "4-Split", + "fiveSplit": "5-Split", + "sixSplit": "6-Split", "availableTabs": "Available Tabs", "dragTabsHint": "Drag tabs into the grid below to position them", "layout": "Layout", @@ -1339,6 +1405,8 @@ "connectionClosed": "Connection closed", "connectionError": "Connection error: {{message}}", "connected": "Connected", + "clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.", + "clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.", "sshConnected": "SSH connection established", "authError": "Authentication failed: {{message}}", "unknownError": "Unknown error occurred", @@ -1393,7 +1461,9 @@ "totpTimeout": "TOTP verification timeout. Please reconnect.", "passwordTimeout": "Password verification timeout. Please reconnect.", "connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.", - "hostKeyRejected": "SSH host key verification rejected. Connection cancelled." + "hostKeyRejected": "SSH host key verification rejected. Connection cancelled.", + "sessionTakenOver": "Session was opened in another tab. Reconnecting...", + "sessionAttachTimeout": "Session attachment timed out. Creating new connection..." }, "fileManager": { "title": "File Manager", @@ -1718,7 +1788,13 @@ "write": "Write", "execute": "Execute", "permissionsChangedSuccessfully": "Permissions changed successfully", - "failedToChangePermissions": "Failed to change permissions" + "failedToChangePermissions": "Failed to change permissions", + "name": "Name", + "sortByName": "Name", + "sortByDate": "Date Modified", + "sortBySize": "Size", + "ascending": "Ascending", + "descending": "Descending" }, "tunnel": { "noTunnelsConfigured": "No Tunnels Configured", @@ -1897,7 +1973,7 @@ "loginButton": "Login", "registerButton": "Register", "forgotPassword": "Forgot Password?", - "rememberMe": "Remember Me", + "rememberMe": "Remember Device for 30 Days (includes TOTP)", "noAccount": "Don't have an account?", "hasAccount": "Already have an account?", "loginSuccess": "Login successful", @@ -2062,6 +2138,7 @@ "processing": "Processing...", "pleaseWait": "Please wait...", "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.", + "userNotAllowed": "Your account is not authorized to register. Please contact an administrator.", "databaseConnected": "Database connected successfully", "databaseConnectionFailed": "Failed to connect to the database server", "checkServerConnection": "Please check your server connection and try again", @@ -2117,7 +2194,9 @@ "appearanceDesc": "Select the color theme for the application", "terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output", "enableCommandPaletteShortcut": "Enable Command Palette Shortcut", - "enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts" + "enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts", + "enableTerminalSessionPersistence": "Persistent Terminal Sessions", + "enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)" }, "user": { "failedToLoadVersionInfo": "Failed to load version information" @@ -2153,6 +2232,7 @@ "usernameField": "name", "scopes": "openid email profile", "userinfoUrl": "https://your-provider.com/application/o/userinfo/", + "allowedUsers": "user@example.com, @company.com", "enterUsername": "Enter username to make admin", "searchHosts": "Search hosts by name, username, IP, folder, tags...", "enterPassword": "Enter your password", diff --git a/src/locales/translated/af_ZA.json b/src/locales/translated/af_ZA.json index 818763b0..b274ae73 100644 --- a/src/locales/translated/af_ZA.json +++ b/src/locales/translated/af_ZA.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Geloofsbriewe", "credentialsViewer": "Geloofsbriewe-kyker", "manageYourSSHCredentials": "Bestuur jou SSH-bewyse veilig", "addCredential": "Voeg geloofsbriewe by", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Dit sal die publieke sleutel veilig by die teikengasheer se ~/.ssh/authorized_keys-lêer voeg sonder om bestaande sleutels te oorskryf. Die bewerking is omkeerbaar.", "chooseHostToDeploy": "Kies 'n gasheer om na te ontplooi...", "deploying": "Implementeer tans...", - "copyDeployCommand": "Kopieer Ontplooi-opdrag", - "copyDeployCommandDescription": "Kopieer 'n dopopdrag om die publieke sleutel handmatig by 'n gasheer se authorized_keys-lêer te voeg. Nuttig wanneer wagwoordverifikasie gedeaktiveer is.", - "deployCommandCopied": "Ontplooi-opdrag gekopieer na knipbord", - "manualDeployInfo": "Plak hierdie opdrag in die teikengasheer se terminaal (bv. Proxmox-konsole, IPMI of fisiese toegang) om die SSH-sleutel by te voeg.", "name": "Naam", "noHostsAvailable": "Geen gashere beskikbaar nie", "noHostsMatchSearch": "Geen gashere stem ooreen met jou soektog nie", @@ -303,9 +298,7 @@ "createFolder": "Skep vouer", "editFolder": "Wysig vouer", "editFolderDescription": "Pas jou brokkie-lêergids aan", - "createFolderDescription": "Organiseer jou brokkies in dopgehou", - "confirmExecution": "Voer \"{{name}}\" uit?", - "confirmExecutionDesc": "Is jy seker jy wil hierdie fragment uitvoer?" + "createFolderDescription": "Organiseer jou brokkies in dopgehou" }, "commandHistory": { "title": "Geskiedenis", @@ -409,7 +402,6 @@ "required": "Vereis", "optional": "Opsioneel", "connect": "Verbind", - "copied": "Gekopieer", "connecting": "Verbind...", "creating": "Skep...", "clear": "Duidelik", @@ -502,7 +494,6 @@ "checking": "Kontroleer...", "checkingDatabase": "Kontroleer databasisverbinding...", "checkingAuthentication": "Kontroleer tans verifikasie...", - "backendReconnected": "Bedienerverbinding herstel", "actions": "Aksies", "remove": "Verwyder", "revoke": "Herroep", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gasheerbestuurder", - "hosts": "Gashere", "sshHosts": "SSH-gashere", "noHosts": "Geen SSH-gashere nie", "noHostsMessage": "Jy het nog geen SSH-gashere bygevoeg nie. Klik \"Voeg gasheer by\" om te begin.", @@ -846,7 +836,7 @@ "failedToImportJson": "Kon nie JSON-lêer invoer nie", "connectionDetails": "Verbindingsbesonderhede", "organization": "Organisasie", - "ipAddress": "IP-adres of gasheernaam", + "ipAddress": "IP-adres", "port": "Hawe", "name": "Naam", "username": "Gebruikersnaam", @@ -925,8 +915,6 @@ "key": "Sleutel", "credential": "Geloofsbrief", "none": "Geen", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Vra jou vir OPKSSH webmagtiging met elke verbinding. Jou magtigingstoken sal 24 uur lank geldig wees totdat dit hernu moet word.", "selectCredential": "Kies geloofsbriewe", "selectCredentialPlaceholder": "Kies 'n geloofsbrief...", "credentialRequired": "Geloofsbriewe word vereis wanneer geloofsbriewe-verifikasie gebruik word", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Sleutelbord-Interaktiewe Verifikasie", "noneAuthDescription": "Hierdie verifikasiemetode sal sleutelbord-interaktiewe verifikasie gebruik wanneer daar aan die SSH-bediener gekoppel word.", "noneAuthDetails": "Sleutelbord-interaktiewe verifikasie laat die bediener toe om jou vir geloofsbriewe te vra tydens verbinding. Dit is nuttig vir bedieners wat multifaktor-verifikasie vereis of as jy nie geloofsbriewe plaaslik wil stoor nie.", - "opksshAuthTitle": "OPKSSH-verifikasie", "forceKeyboardInteractive": "Forseer sleutelbord-interaktief", "forceKeyboardInteractiveDesc": "Dwing die gebruik van sleutelbord-interaktiewe verifikasie af. Dit word soms vereis vir bedieners wat tweefaktor-verifikasie (TOTP/2FA) gebruik.", "overrideCredentialUsername": "Oorskryf geloofsbriewe gebruikersnaam", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Maak oop in blaaier", "warpgateContinue": "Ek het verifikasie voltooi", "warpgateTimeout": "Warpgate-verifikasietydverstryking. Koppel asseblief weer.", - "opksshAuthRequired": "OPKSSH-verifikasie vereis", - "opksshAuthDescription": "Voltooi verifikasie in jou blaaier om voort te gaan. Hierdie sessie sal vir 24 uur geldig bly.", - "opksshAuthUrl": "Verifikasie-URL", - "opksshOpenBrowser": "Maak blaaier oop om te verifieer", - "opksshWaitingForAuth": "Wag vir verifikasie in blaaier...", - "opksshAuthenticating": "Verwerk verifikasie...", - "opksshTimeout": "Verifikasie het verstryk. Probeer asseblief weer.", - "opksshAuthFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.", - "opksshConfigMissing": "OPKSSH-konfigurasie nie gevind nie. Skep asseblief ~/.opk/config.yml met jou OIDC-verskafferinstellings. Sien dokumentasie: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Wagwoord invoeg?", "sudoPasswordPopupHint": "Druk Enter om in te voeg, Esc om te verwerp", "sudoPasswordPopupConfirm": "Invoeg", @@ -1359,8 +1337,7 @@ "automaticFallback": "Probeer outomaties {{method}} verifikasie...", "totpTimeout": "TOTP-verifikasie het uitgeloop. Koppel asseblief weer aan.", "passwordTimeout": "Wagwoordverifikasie het uitgeloop. Koppel asseblief weer aan.", - "connectionRejected": "Verbinding deur bediener verwerp. Kontroleer asseblief u verifikasie en netwerkkonfigurasie.", - "hostKeyRejected": "SSH-gasheersleutelverifikasie verwerp. Verbinding gekanselleer." + "connectionRejected": "Verbinding deur bediener verwerp. Kontroleer asseblief u verifikasie en netwerkkonfigurasie." }, "fileManager": { "title": "Lêerbestuurder", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Wagwoordherstel suksesvol", "passwordResetSuccessDesc": "Jou wagwoord is suksesvol herstel. Jy kan nou met jou nuwe wagwoord aanmeld." }, - "hostKey": { - "verifyNewHost": "Verifieer SSH-gasheersleutel", - "keyChangedWarning": "SSH-gasheersleutel verander", - "firstConnectionTitle": "Eerste keer dat jy met hierdie gasheer verbind", - "firstConnectionDescription": "Die egtheid van hierdie gasheer kan nie vasgestel word nie. Verifieer dat die vingerafdruk ooreenstem met wat jy verwag.", - "keyChangedDescription": "Die gasheersleutel vir hierdie bediener het verander sedert jou laaste verbinding. Dit kan op 'n sekuriteitsprobleem dui.", - "previousKey": "Vorige Sleutel", - "newFingerprint": "Nuwe vingerafdruk", - "fingerprint": "Vingerafdruk", - "verifyInstructions": "As jy hierdie gasheer vertrou, klik Aanvaar om voort te gaan en hierdie vingerafdruk vir toekomstige verbindings te stoor.", - "securityWarning": "Sekuriteitswaarskuwing", - "acceptAndContinue": "Aanvaar en Gaan voort", - "acceptNewKey": "Aanvaar Nuwe Sleutel & Gaan Voort" - }, "errors": { "notFound": "Bladsy nie gevind nie", "unauthorized": "Ongemagtigde toegang", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminaal", "hostSidebarSettings": "Gasheer en sybalk", "snippetsSettings": "Brokkies", - "confirmSnippetExecution": "Bevestig Uitvoering van Brokkie", - "confirmSnippetExecutionDesc": "Wys bevestigingsdialoog voordat brokkies uitgevoer word", "updateSettings": "Opdaterings", "disableUpdateCheck": "Deaktiveer Opdateringskontrole", "disableUpdateCheckDesc": "Hou op om vir nuwe weergawes te kyk tydens opstart en op die dashboard. Verminder netwerkversoeke.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 of example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/ar_SA.json b/src/locales/translated/ar_SA.json index f506847a..f3858d8c 100644 --- a/src/locales/translated/ar_SA.json +++ b/src/locales/translated/ar_SA.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "وثائق التفويض", "credentialsViewer": "عارض وثائق التفويض", "manageYourSSHCredentials": "إدارة بيانات اعتماد SSH الخاصة بك بشكل آمن", "addCredential": "إضافة بيانات اعتماد", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "سيؤدي هذا بأمان إلى إضافة المفتاح العمومي إلى ملف المضيف الهدف ~/.ssh/authorized_keyys دون الكتابة فوق المفاتيح الموجودة. العملية قابلة للرجوع عنها.", "chooseHostToDeploy": "اختر مضيف ليقوم بنشره إلى...", "deploying": "النشر...", - "copyDeployCommand": "نسخ أمر النشر", - "copyDeployCommandDescription": "نسخ أمر Shell لإضافة المفتاح العام يدوياً إلى ملف مفتاح_مفاتيح مضيف مضيف. مفيد عندما يتم تعطيل مصادقة كلمة المرور.", - "deployCommandCopied": "نشر أمر نسخ إلى الحافظة", - "manualDeployInfo": "قم بلصق هذا الأمر في محطة المضيف المستهدفة (مثل وحدة تحكم Proxmox أو IPMI، أو الوصول المادي) لإضافة مفتاح SSH.", "name": "الاسم", "noHostsAvailable": "لا يوجد مضيفين متاحين", "noHostsMatchSearch": "لا يوجد مضيف يطابق بحثك", @@ -303,9 +298,7 @@ "createFolder": "إنشاء مجلد", "editFolder": "تعديل المجلد", "editFolderDescription": "تخصيص مجلد كتلة الكود", - "createFolderDescription": "تنظيم كتل الكتل في مجلدات", - "confirmExecution": "تنفيذ \"{{name}}\"؟", - "confirmExecutionDesc": "هل أنت متأكد من أنك تريد تنفيذ كتلة الكود هذه؟" + "createFolderDescription": "تنظيم كتل الكتل في مجلدات" }, "commandHistory": { "title": "التاريخ", @@ -409,7 +402,6 @@ "required": "مطلوب", "optional": "اختياري", "connect": "الاتصال", - "copied": "منسوخ", "connecting": "جاري الاتصال...", "creating": "إنشاء...", "clear": "مسح", @@ -502,7 +494,6 @@ "checking": "يتحقق...", "checkingDatabase": "التحقق من اتصال قاعدة البيانات...", "checkingAuthentication": "التحقق من المصادقة...", - "backendReconnected": "تم استعادة اتصال الخادم", "actions": "الإجراءات", "remove": "إزالة", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "مدير المضيف", - "hosts": "المضيفون", "sshHosts": "مضيفي SSH", "noHosts": "لا يوجد مضيفين SSH", "noHostsMessage": "لم تقم بإضافة أي مضيف SSH حتى الآن. انقر فوق \"إضافة مضيف\" للبدء.", @@ -846,7 +836,7 @@ "failedToImportJson": "فشل استيراد ملف JSON", "connectionDetails": "تفاصيل الاتصال", "organization": "المنظمة", - "ipAddress": "عنوان IP أو اسم المضيف", + "ipAddress": "عنوان IP", "port": "المنفذ", "name": "الاسم", "username": "اسم المستخدم", @@ -925,8 +915,6 @@ "key": "المفتاح", "credential": "بيانات", "none": "لا", - "opkssh": "فتح", - "opksshAuthDescription": "يمنحك الحصول على مصادقة ويب OPKSSH عند كل اتصال. رمز المصادقة الخاص بك سوف يستمر لمدة 24 ساعة حتى يجب تجديده.", "selectCredential": "حدد بيانات الاعتماد", "selectCredentialPlaceholder": "اختر بيانات الاعتماد...", "credentialRequired": "الاعتماد مطلوب عند استخدام مصادقة الاعتماد", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "المصادقة التفاعلية على لوحة المفاتيح", "noneAuthDescription": "طريقة المصادقة هذه ستستخدم مصادقة لوحة المفاتيح التفاعلية عند الاتصال بخادم SSH.", "noneAuthDetails": "المصادقة التفاعلية بلوحة المفاتيح تسمح للخادم بمطالبتك للحصول على بيانات الاعتماد أثناء الاتصال. هذا مفيد للخوادم التي تتطلب مصادقة متعددة العناصر أو إذا كنت لا تريد حفظ بيانات الاعتماد محلياً.", - "opksshAuthTitle": "المصادقة المفتوحة", "forceKeyboardInteractive": "تفعيل لوحة المفاتيح", "forceKeyboardInteractiveDesc": "يفرض استخدام مصادقة لوحة المفاتيح التفاعلية. هذا مطلوب أحيانا للخوادم التي تستخدم المصادقة الثنائية (TOTP/2FA).", "overrideCredentialUsername": "تجاوز اسم المستخدم", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "فتح في المتصفح", "warpgateContinue": "لقد أكملت المصادقة", "warpgateTimeout": "انتهت مهلة مصادقة التحذير. الرجاء إعادة الاتصال.", - "opksshAuthRequired": "المصادقة مفتوحة مطلوبة", - "opksshAuthDescription": "أكمل المصادقة في المتصفح الخاص بك للمتابعة. ستبقى هذه الجلسة صالحة لمدة 24 ساعة.", - "opksshAuthUrl": "رابط المصادقة", - "opksshOpenBrowser": "فتح المتصفح للمصادقة", - "opksshWaitingForAuth": "في انتظار المصادقة في المتصفح...", - "opksshAuthenticating": "جاري معالجة المصادقة...", - "opksshTimeout": "انتهت مهلة المصادقة. الرجاء المحاولة مرة أخرى.", - "opksshAuthFailed": "فشلت المصادقة. الرجاء التحقق من بيانات الاعتماد الخاصة بك وحاول مرة أخرى.", - "opksshConfigMissing": "لم يتم العثور على تكوين الفتح. الرجاء إنشاء ~/.opk/config.yml مع إعدادات موفر OIDC. انظر الوثائق: https://github.com/openpubkey/opkssh#config.", "sudoPasswordPopupTitle": "إدراج كلمة المرور؟", "sudoPasswordPopupHint": "اضغط Enter للإدراج ، Esc للإلغاء", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "جرب مصادقة {{method}} تلقائياً...", "totpTimeout": "انتهت مهلة التحقق من TOTP. الرجاء إعادة الاتصال.", "passwordTimeout": "انتهت مهلة التحقق من كلمة المرور. الرجاء إعادة الاتصال.", - "connectionRejected": "تم رفض الاتصال بواسطة الخادم. الرجاء التحقق من المصادقة الخاصة بك وتكوين الشبكة.", - "hostKeyRejected": "تم رفض التحقق من مفتاح المضيف SSH. تم إلغاء الاتصال." + "connectionRejected": "تم رفض الاتصال بواسطة الخادم. الرجاء التحقق من المصادقة الخاصة بك وتكوين الشبكة." }, "fileManager": { "title": "مدير الملفات", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "تم إعادة تعيين كلمة المرور بنجاح", "passwordResetSuccessDesc": "تم إعادة تعيين كلمة المرور الخاصة بك بنجاح. يمكنك الآن تسجيل الدخول باستخدام كلمة المرور الجديدة." }, - "hostKey": { - "verifyNewHost": "التحقق من مفتاح استضافة SSH", - "keyChangedWarning": "تم تغيير مفتاح المضيف SSH", - "firstConnectionTitle": "أول مرة تتصل بهذا المضيف", - "firstConnectionDescription": "لا يمكن إنشاء صحة هذا المضيف. تحقق من بصمة الإصبع تطابق ما تتوقعه.", - "keyChangedDescription": "مفتاح المضيف لهذا الخادم قد تغير منذ الاتصال الأخير. يمكن أن يشير هذا إلى مشكلة أمنية.", - "previousKey": "المفتاح السابق", - "newFingerprint": "بصمة جديدة", - "fingerprint": "بصمة", - "verifyInstructions": "إذا كنت تثق بهذا المضيف، انقر فوق قبول للمتابعة وحفظ بصمة الإصبع هذه للاتصال في المستقبل.", - "securityWarning": "تحذير الأمان", - "acceptAndContinue": "قبول ومتابعة", - "acceptNewKey": "قبول المفتاح الجديد ومتابعة" - }, "errors": { "notFound": "لم يتم العثور على الصفحة", "unauthorized": "الوصول غير المصرح به", @@ -2063,8 +2026,6 @@ "terminalSettings": "المحطة", "hostSidebarSettings": "المضيف و الشريط الجانبي", "snippetsSettings": "كتل الكود", - "confirmSnippetExecution": "تأكيد تنفيذ كتلة الكود", - "confirmSnippetExecutionDesc": "إظهار مربع حوار التأكيد قبل تنفيذ كتل الكود", "updateSettings": "التحديثات", "disableUpdateCheck": "تعطيل التحقق من التحديث", "disableUpdateCheckDesc": "إيقاف التحقق من الإصدارات الجديدة عند بدء التشغيل و لوحة التحكم. يقلل من طلبات الشبكة.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1-1 أو مثال.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/bg_BG.json b/src/locales/translated/bg_BG.json index 6d9008d8..a34aad64 100644 --- a/src/locales/translated/bg_BG.json +++ b/src/locales/translated/bg_BG.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Пълномощия", "credentialsViewer": "Преглед на идентификационни данни", "manageYourSSHCredentials": "Управлявайте сигурно вашите SSH идентификационни данни", "addCredential": "Добавяне на идентификационни данни", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Това безопасно ще добави публичния ключ към файла ~/.ssh/authorized_keys на целевия хост, без да презаписва съществуващите ключове. Операцията е обратима.", "chooseHostToDeploy": "Изберете хост, на който да се разположи...", "deploying": "Разгръщане...", - "copyDeployCommand": "Копиране на командата за разгръщане", - "copyDeployCommandDescription": "Копирайте команда от shell, за да добавите ръчно публичния ключ към файла authorized_keys на хоста. Полезно е, когато удостоверяването с парола е деактивирано.", - "deployCommandCopied": "Командата за разгръщане е копирана в клипборда", - "manualDeployInfo": "Поставете тази команда в терминала на целевия хост (напр. конзола на Proxmox, IPMI или физически достъп), за да добавите SSH ключа.", "name": "Име", "noHostsAvailable": "Няма налични хостове", "noHostsMatchSearch": "Няма хостове, които да отговарят на вашето търсене", @@ -303,9 +298,7 @@ "createFolder": "Създаване на папка", "editFolder": "Редактиране на папка", "editFolderDescription": "Персонализирайте папката си с фрагменти", - "createFolderDescription": "Организирайте фрагментите си в папки", - "confirmExecution": "Да се изпълни „{{name}}“?", - "confirmExecutionDesc": "Сигурни ли сте, че искате да изпълните този фрагмент?" + "createFolderDescription": "Организирайте фрагментите си в папки" }, "commandHistory": { "title": "История", @@ -409,7 +402,6 @@ "required": "Задължително", "optional": "По избор", "connect": "Свържете се", - "copied": "Копирано", "connecting": "Свързване...", "creating": "Създаване...", "clear": "Изчисти", @@ -502,7 +494,6 @@ "checking": "Проверка...", "checkingDatabase": "Проверка на връзката с базата данни...", "checkingAuthentication": "Проверка на удостоверяването...", - "backendReconnected": "Връзката със сървъра е възстановена", "actions": "Действия", "remove": "Премахване", "revoke": "Отмяна", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Мениджър на домакини", - "hosts": "Домакини", "sshHosts": "SSH хостове", "noHosts": "Няма SSH хостове", "noHostsMessage": "Все още не сте добавили SSH хостове. Кликнете върху „Добавяне на хост“, за да започнете.", @@ -846,7 +836,7 @@ "failedToImportJson": "Импортирането на JSON файл не бе успешно", "connectionDetails": "Детайли за връзката", "organization": "Организация", - "ipAddress": "IP адрес или име на хост", + "ipAddress": "IP адрес", "port": "Порт", "name": "Име", "username": "Потребителско име", @@ -925,8 +915,6 @@ "key": "Ключ", "credential": "Пълномощия", "none": "Няма", - "opkssh": "ОПКСШ", - "opksshAuthDescription": "Подканва ви за OPKSSH уеб-авторизация при всяко свързване. Вашият токен за удостоверяване ще бъде валиден 24 часа, докато не се наложи да бъде подновен.", "selectCredential": "Изберете идентификационни данни", "selectCredentialPlaceholder": "Изберете удостоверение за самоличност...", "credentialRequired": "Изисква се удостоверяване на идентификационни данни при използване на удостоверяване с идентификационни данни", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Интерактивно удостоверяване с клавиатура", "noneAuthDescription": "Този метод за удостоверяване ще използва интерактивно удостоверяване с клавиатура при свързване към SSH сървъра.", "noneAuthDetails": "Интерактивното удостоверяване с клавиатура позволява на сървъра да ви пита за идентификационни данни по време на връзка. Това е полезно за сървъри, които изискват многофакторно удостоверяване или ако не искате да запазвате идентификационните данни локално.", - "opksshAuthTitle": "OPKSSH удостоверяване", "forceKeyboardInteractive": "Принудително интерактивно с клавиатура", "forceKeyboardInteractiveDesc": "Налага използването на интерактивно удостоверяване с клавиатура. Това понякога е необходимо за сървъри, които използват двуфакторно удостоверяване (TOTP/2FA).", "overrideCredentialUsername": "Замяна на потребителско име за идентификационни данни", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Отваряне в браузъра", "warpgateContinue": "Завърших удостоверяването", "warpgateTimeout": "Времето за изчакване за удостоверяване на Warpgate изтече. Моля, свържете се отново.", - "opksshAuthRequired": "Изисква се удостоверяване на OPKSSH", - "opksshAuthDescription": "За да продължите, завършете удостоверяването в браузъра си. Тази сесия ще остане валидна 24 часа.", - "opksshAuthUrl": "URL адрес за удостоверяване", - "opksshOpenBrowser": "Отворете браузъра за удостоверяване", - "opksshWaitingForAuth": "Чака се удостоверяване в браузъра...", - "opksshAuthenticating": "Обработва се удостоверяване...", - "opksshTimeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново.", - "opksshAuthFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.", - "opksshConfigMissing": "Конфигурацията на OPKSSH не е намерена. Моля, създайте ~/.opk/config.yml с настройките на вашия OIDC доставчик. Вижте документацията: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Въвеждане на парола?", "sudoPasswordPopupHint": "Натиснете Enter за вмъкване, Esc за отхвърляне", "sudoPasswordPopupConfirm": "Вмъкване", @@ -1359,8 +1337,7 @@ "automaticFallback": "Автоматичен опит за {{method}} удостоверяване...", "totpTimeout": "Времето за изчакване за проверка на TOTP изтече. Моля, свържете се отново.", "passwordTimeout": "Времето за изчакване за потвърждение на паролата изтече. Моля, свържете се отново.", - "connectionRejected": "Връзката е отхвърлена от сървъра. Моля, проверете удостоверяването и мрежовата си конфигурация.", - "hostKeyRejected": "Проверката на SSH ключа за хост е отхвърлена. Връзката е прекратена." + "connectionRejected": "Връзката е отхвърлена от сървъра. Моля, проверете удостоверяването и мрежовата си конфигурация." }, "fileManager": { "title": "Файлов мениджър", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Успешно възстановяване на паролата", "passwordResetSuccessDesc": "Паролата ви е нулирана успешно. Вече можете да влезете с новата си парола." }, - "hostKey": { - "verifyNewHost": "Проверка на SSH хост ключа", - "keyChangedWarning": "SSH ключът на хоста е променен", - "firstConnectionTitle": "Първо свързване с този хост", - "firstConnectionDescription": "Автентичността на този хост не може да бъде установена. Проверете дали пръстовият отпечатък съответства на очакванията ви.", - "keyChangedDescription": "Ключът на хоста за този сървър се е променил от последното ви свързване. Това може да показва проблем със сигурността.", - "previousKey": "Предишен ключ", - "newFingerprint": "Нов пръстов отпечатък", - "fingerprint": "Пръстов отпечатък", - "verifyInstructions": "Ако имате доверие на този хост, щракнете върху „Приемам“, за да продължите и да запазите този пръстов отпечатък за бъдещи връзки.", - "securityWarning": "Предупреждение за сигурност", - "acceptAndContinue": "Приемам и продължавам", - "acceptNewKey": "Приемете новия ключ и продължете" - }, "errors": { "notFound": "Страницата не е намерена", "unauthorized": "Неоторизиран достъп", @@ -2063,8 +2026,6 @@ "terminalSettings": "Терминал", "hostSidebarSettings": "Хост и странична лента", "snippetsSettings": "Откъси", - "confirmSnippetExecution": "Потвърждаване на изпълнението на фрагмента", - "confirmSnippetExecutionDesc": "Показване на диалогов прозорец за потвърждение преди изпълнение на фрагменти", "updateSettings": "Актуализации", "disableUpdateCheck": "Деактивиране на проверката за актуализации", "disableUpdateCheckDesc": "Спрете проверката за нови версии при стартиране и таблото за управление. Намалява мрежовите заявки.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 или example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/bn_BD.json b/src/locales/translated/bn_BD.json index 333462f1..c1bedc8e 100644 --- a/src/locales/translated/bn_BD.json +++ b/src/locales/translated/bn_BD.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "পরিচয়পত্র", "credentialsViewer": "শংসাপত্র দর্শক", "manageYourSSHCredentials": "আপনার SSH শংসাপত্রগুলি নিরাপদে পরিচালনা করুন", "addCredential": "শংসাপত্র যোগ করুন", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "এটি বিদ্যমান কীগুলিকে ওভাররাইট না করেই টার্গেট হোস্টের ~/.ssh/authorized_keys ফাইলে নিরাপদে পাবলিক কী যুক্ত করবে। এই অপারেশনটি বিপরীতমুখী।", "chooseHostToDeploy": "স্থাপনের জন্য একটি হোস্ট বেছে নিন...", "deploying": "স্থাপন করা হচ্ছে...", - "copyDeployCommand": "ডিপ্লয় কমান্ড কপি করুন", - "copyDeployCommandDescription": "হোস্টের authorized_keys ফাইলে পাবলিক কী ম্যানুয়ালি যোগ করার জন্য একটি শেল কমান্ড কপি করুন। পাসওয়ার্ড প্রমাণীকরণ অক্ষম থাকলে এটি কার্যকর।", - "deployCommandCopied": "ডিপ্লয় কমান্ড ক্লিপবোর্ডে কপি করা হয়েছে", - "manualDeployInfo": "SSH কী যোগ করতে টার্গেট হোস্টের টার্মিনালে (যেমন, Proxmox কনসোল, IPMI, অথবা ফিজিক্যাল অ্যাক্সেস) এই কমান্ডটি পেস্ট করুন।", "name": "নাম", "noHostsAvailable": "কোনও হোস্ট উপলব্ধ নেই", "noHostsMatchSearch": "আপনার অনুসন্ধানের সাথে কোনও হোস্ট মেলেনি।", @@ -303,9 +298,7 @@ "createFolder": "ফোল্ডার তৈরি করুন", "editFolder": "ফোল্ডার সম্পাদনা করুন", "editFolderDescription": "আপনার স্নিপেট ফোল্ডারটি কাস্টমাইজ করুন", - "createFolderDescription": "আপনার স্নিপেটগুলিকে ফোল্ডারে সাজান", - "confirmExecution": "\"{{name}}\" কার্যকর করবেন?", - "confirmExecutionDesc": "আপনি কি নিশ্চিত যে আপনি এই স্নিপেটটি কার্যকর করতে চান?" + "createFolderDescription": "আপনার স্নিপেটগুলিকে ফোল্ডারে সাজান" }, "commandHistory": { "title": "ইতিহাস", @@ -409,7 +402,6 @@ "required": "প্রয়োজনীয়", "optional": "ঐচ্ছিক", "connect": "সংযোগ করুন", - "copied": "কপি করা হয়েছে", "connecting": "সংযোগ করা হচ্ছে...", "creating": "তৈরি করা হচ্ছে...", "clear": "পরিষ্কার", @@ -502,7 +494,6 @@ "checking": "পরীক্ষা করা হচ্ছে...", "checkingDatabase": "ডাটাবেস সংযোগ পরীক্ষা করা হচ্ছে...", "checkingAuthentication": "প্রমাণীকরণ পরীক্ষা করা হচ্ছে...", - "backendReconnected": "সার্ভার সংযোগ পুনরুদ্ধার করা হয়েছে", "actions": "কর্ম", "remove": "অপসারণ", "revoke": "প্রত্যাহার করুন", @@ -815,7 +806,6 @@ }, "hosts": { "title": "হোস্ট ম্যানেজার", - "hosts": "হোস্ট", "sshHosts": "SSH হোস্ট", "noHosts": "কোনও SSH হোস্ট নেই", "noHostsMessage": "আপনি এখনও কোনও SSH হোস্ট যোগ করেননি। শুরু করতে \"হোস্ট যোগ করুন\" এ ক্লিক করুন।", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON ফাইল আমদানি করা যায়নি", "connectionDetails": "সংযোগের বিবরণ", "organization": "সংগঠন", - "ipAddress": "আইপি ঠিকানা অথবা হোস্টনেম", + "ipAddress": "আইপি ঠিকানা", "port": "বন্দর", "name": "নাম", "username": "ব্যবহারকারীর নাম", @@ -925,8 +915,6 @@ "key": "চাবি", "credential": "পরিচয়পত্র", "none": "কোনটিই নয়", - "opkssh": "OPKSSH সম্পর্কে", - "opksshAuthDescription": "প্রতিটি সংযোগের সময় আপনাকে OPKSSH ওয়েব-অনুমোদনের জন্য অনুরোধ করবে। আপনার অথ টোকেনটি পুনর্নবীকরণ না করা পর্যন্ত 24 ঘন্টা স্থায়ী হবে।", "selectCredential": "শংসাপত্র নির্বাচন করুন", "selectCredentialPlaceholder": "একটি শংসাপত্র বেছে নিন...", "credentialRequired": "শংসাপত্র প্রমাণীকরণ ব্যবহার করার সময় শংসাপত্র প্রয়োজন", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ", "noneAuthDescription": "এই প্রমাণীকরণ পদ্ধতিটি SSH সার্ভারের সাথে সংযোগ করার সময় কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ ব্যবহার করবে।", "noneAuthDetails": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণের মাধ্যমে সার্ভার সংযোগের সময় আপনাকে শংসাপত্রের জন্য অনুরোধ করতে পারে। এটি এমন সার্ভারগুলির জন্য কার্যকর যাদের মাল্টি-ফ্যাক্টর প্রমাণীকরণের প্রয়োজন হয় অথবা যদি আপনি স্থানীয়ভাবে শংসাপত্র সংরক্ষণ করতে না চান।", - "opksshAuthTitle": "OPKSSH প্রমাণীকরণ", "forceKeyboardInteractive": "জোর করে কীবোর্ড-ইন্টারেক্টিভ", "forceKeyboardInteractiveDesc": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ ব্যবহার করতে বাধ্য করে। কখনও কখনও টু-ফ্যাক্টর প্রমাণীকরণ (TOTP/2FA) ব্যবহার করে এমন সার্ভারগুলির জন্য এটি প্রয়োজন হয়।", "overrideCredentialUsername": "শংসাপত্র ব্যবহারকারীর নাম ওভাররাইড করুন", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "ব্রাউজারে খুলুন", "warpgateContinue": "আমি প্রমাণীকরণ সম্পন্ন করেছি।", "warpgateTimeout": "ওয়ার্পগেট প্রমাণীকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।", - "opksshAuthRequired": "OPKSSH প্রমাণীকরণ প্রয়োজন", - "opksshAuthDescription": "চালিয়ে যেতে আপনার ব্রাউজারে প্রমাণীকরণ সম্পূর্ণ করুন। এই সেশনটি 24 ঘন্টার জন্য বৈধ থাকবে।", - "opksshAuthUrl": "প্রমাণীকরণ URL", - "opksshOpenBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন", - "opksshWaitingForAuth": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...", - "opksshAuthenticating": "প্রমাণীকরণ প্রক্রিয়া করা হচ্ছে...", - "opksshTimeout": "প্রমাণীকরণের সময় শেষ। অনুগ্রহ করে আবার চেষ্টা করুন।", - "opksshAuthFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার শংসাপত্রগুলি পরীক্ষা করে আবার চেষ্টা করুন।", - "opksshConfigMissing": "OPKSSH কনফিগারেশন খুঁজে পাওয়া যায়নি। আপনার OIDC প্রোভাইডার সেটিংস দিয়ে ~/.opk/config.yml তৈরি করুন। ডকুমেন্টেশন দেখুন: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "পাসওয়ার্ড ঢোকাবেন?", "sudoPasswordPopupHint": "সন্নিবেশ করতে Enter টিপুন এবং খারিজ করতে Esc টিপুন", "sudoPasswordPopupConfirm": "ঢোকান", @@ -1359,8 +1337,7 @@ "automaticFallback": "স্বয়ংক্রিয়ভাবে {{method}} প্রমাণীকরণ চেষ্টা করা হচ্ছে...", "totpTimeout": "TOTP যাচাইকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।", "passwordTimeout": "পাসওয়ার্ড যাচাইকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।", - "connectionRejected": "সার্ভার সংযোগ প্রত্যাখ্যান করেছে। অনুগ্রহ করে আপনার প্রমাণীকরণ এবং নেটওয়ার্ক কনফিগারেশন পরীক্ষা করুন।", - "hostKeyRejected": "SSH হোস্ট কী যাচাইকরণ প্রত্যাখ্যাত হয়েছে। সংযোগ বাতিল করা হয়েছে।" + "connectionRejected": "সার্ভার সংযোগ প্রত্যাখ্যান করেছে। অনুগ্রহ করে আপনার প্রমাণীকরণ এবং নেটওয়ার্ক কনফিগারেশন পরীক্ষা করুন।" }, "fileManager": { "title": "ফাইল ম্যানেজার", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "পাসওয়ার্ড রিসেট সফল হয়েছে", "passwordResetSuccessDesc": "আপনার পাসওয়ার্ড সফলভাবে রিসেট করা হয়েছে। আপনি এখন আপনার নতুন পাসওয়ার্ড দিয়ে লগ ইন করতে পারেন।" }, - "hostKey": { - "verifyNewHost": "SSH হোস্ট কী যাচাই করুন", - "keyChangedWarning": "SSH হোস্ট কী পরিবর্তন করা হয়েছে", - "firstConnectionTitle": "এই হোস্টের সাথে প্রথমবার সংযোগ করা হচ্ছে", - "firstConnectionDescription": "এই হোস্টের সত্যতা যাচাই করা যাচ্ছে না। আপনার প্রত্যাশার সাথে আঙুলের ছাপের মিল আছে কিনা তা যাচাই করুন।", - "keyChangedDescription": "আপনার শেষ সংযোগের পর থেকে এই সার্ভারের হোস্ট কী পরিবর্তিত হয়েছে। এটি কোনও নিরাপত্তা সমস্যা নির্দেশ করতে পারে।", - "previousKey": "পূর্ববর্তী কী", - "newFingerprint": "নতুন ফিঙ্গারপ্রিন্ট", - "fingerprint": "আঙুলের ছাপ", - "verifyInstructions": "যদি আপনি এই হোস্টটিকে বিশ্বাস করেন, তাহলে চালিয়ে যেতে \"গ্রহণ করুন\" এ ক্লিক করুন এবং ভবিষ্যতের সংযোগের জন্য এই আঙ্গুলের ছাপটি সংরক্ষণ করুন।", - "securityWarning": "নিরাপত্তা সতর্কতা", - "acceptAndContinue": "গ্রহণ করুন এবং চালিয়ে যান", - "acceptNewKey": "নতুন কী গ্রহণ করুন এবং চালিয়ে যান" - }, "errors": { "notFound": "পৃষ্ঠাটি খুঁজে পাওয়া যায়নি", "unauthorized": "অননুমোদিত প্রবেশাধিকার", @@ -2063,8 +2026,6 @@ "terminalSettings": "টার্মিনাল", "hostSidebarSettings": "হোস্ট এবং সাইডবার", "snippetsSettings": "স্নিপেটস", - "confirmSnippetExecution": "স্নিপেট এক্সিকিউশন নিশ্চিত করুন", - "confirmSnippetExecutionDesc": "স্নিপেট চালানোর আগে নিশ্চিতকরণ ডায়ালগ দেখান", "updateSettings": "আপডেট", "disableUpdateCheck": "আপডেট চেক অক্ষম করুন", "disableUpdateCheckDesc": "স্টার্টআপ এবং ড্যাশবোর্ডে নতুন সংস্করণ পরীক্ষা করা বন্ধ করুন। নেটওয়ার্ক অনুরোধ হ্রাস করে।", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "০০০০০০", - "ipAddress": "১৯২.১৬৮.১.১ অথবা example.com", + "ipAddress": "১২৭.০.০.১", "port": "২২", "maxRetries": "৩", "retryInterval": "১০", diff --git a/src/locales/translated/ca_ES.json b/src/locales/translated/ca_ES.json index 47603f0b..9a5271a9 100644 --- a/src/locales/translated/ca_ES.json +++ b/src/locales/translated/ca_ES.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Credencials", "credentialsViewer": "Visualitzador de credencials", "manageYourSSHCredentials": "Gestiona les teves credencials SSH de manera segura", "addCredential": "Afegeix credencials", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Això afegirà de manera segura la clau pública al fitxer ~/.ssh/authorized_keys de l'amfitrió de destinació sense sobreescriure les claus existents. L'operació és reversible.", "chooseHostToDeploy": "Trieu un amfitrió on implementar...", "deploying": "S'està desplegant...", - "copyDeployCommand": "Copia l'ordre de desplegament", - "copyDeployCommandDescription": "Copia una ordre de shell per afegir manualment la clau pública al fitxer authorized_keys d'un host. Útil quan l'autenticació amb contrasenya està desactivada.", - "deployCommandCopied": "L'ordre de desplegament s'ha copiat al porta-retalls", - "manualDeployInfo": "Enganxeu aquesta ordre al terminal de l'amfitrió de destinació (per exemple, la consola de Proxmox, IPMI o l'accés físic) per afegir la clau SSH.", "name": "Nom", "noHostsAvailable": "No hi ha amfitrions disponibles", "noHostsMatchSearch": "No hi ha amfitrions que coincideixin amb la teva cerca", @@ -303,9 +298,7 @@ "createFolder": "Crea una carpeta", "editFolder": "Edita la carpeta", "editFolderDescription": "Personalitza la carpeta de fragments", - "createFolderDescription": "Organitza els teus fragments en carpetes", - "confirmExecution": "Executar \"{{name}}\"?", - "confirmExecutionDesc": "Esteu segur que voleu executar aquest fragment?" + "createFolderDescription": "Organitza els teus fragments en carpetes" }, "commandHistory": { "title": "Història", @@ -409,7 +402,6 @@ "required": "Obligatori", "optional": "Opcional", "connect": "Connecta", - "copied": "Copiat", "connecting": "Connectant...", "creating": "Creant...", "clear": "Clar", @@ -502,7 +494,6 @@ "checking": "S'està revisant...", "checkingDatabase": "S'està comprovant la connexió a la base de dades...", "checkingAuthentication": "S'està comprovant l'autenticació...", - "backendReconnected": "Connexió al servidor restaurada", "actions": "Accions", "remove": "Elimina", "revoke": "Revocar", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gestor d'amfitrions", - "hosts": "Amfitrions", "sshHosts": "Amfitrions SSH", "noHosts": "Sense amfitrions SSH", "noHostsMessage": "Encara no has afegit cap amfitrió SSH. Fes clic a \"Afegeix amfitrió\" per començar.", @@ -846,7 +836,7 @@ "failedToImportJson": "No s'ha pogut importar el fitxer JSON", "connectionDetails": "Detalls de la connexió", "organization": "Organització", - "ipAddress": "Adreça IP o nom d'amfitrió", + "ipAddress": "Adreça IP", "port": "Port", "name": "Nom", "username": "Nom d'usuari", @@ -925,8 +915,6 @@ "key": "Clau", "credential": "Credencial", "none": "Cap", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Et demana l'autenticació web OPKSSH cada vegada que et connectes. El teu testimoni d'autenticació durarà 24 hores fins que s'hagi de renovar.", "selectCredential": "Selecciona la credencial", "selectCredentialPlaceholder": "Trieu una credencial...", "credentialRequired": "Cal tenir credencials quan s'utilitza l'autenticació de credencials", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autenticació interactiva amb teclat", "noneAuthDescription": "Aquest mètode d'autenticació utilitzarà l'autenticació interactiva del teclat quan es connecti al servidor SSH.", "noneAuthDetails": "L'autenticació interactiva amb teclat permet que el servidor us demani credencials durant la connexió. Això és útil per a servidors que requereixen autenticació multifactor o si no voleu desar les credencials localment.", - "opksshAuthTitle": "Autenticació OPKSSH", "forceKeyboardInteractive": "Forçar la interacció del teclat", "forceKeyboardInteractiveDesc": "Força l'ús de l'autenticació interactiva amb teclat. De vegades, això és necessari per a servidors que utilitzen l'autenticació de dos factors (TOTP/2FA).", "overrideCredentialUsername": "Substitueix les credencials i el nom d'usuari", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Obre al navegador", "warpgateContinue": "He completat l'autenticació", "warpgateTimeout": "Temps d'espera d'autenticació de Warpgate. Si us plau, torneu a connectar-vos.", - "opksshAuthRequired": "Cal autenticació OPKSSH", - "opksshAuthDescription": "Completeu l'autenticació al navegador per continuar. Aquesta sessió serà vàlida durant 24 hores.", - "opksshAuthUrl": "URL d'autenticació", - "opksshOpenBrowser": "Obre el navegador per autenticar-te", - "opksshWaitingForAuth": "Esperant l'autenticació al navegador...", - "opksshAuthenticating": "Processant l'autenticació...", - "opksshTimeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar.", - "opksshAuthFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.", - "opksshConfigMissing": "No s'ha trobat la configuració d'OPKSSH. Si us plau, creeu ~/.opk/config.yml amb la configuració del vostre proveïdor OIDC. Vegeu la documentació: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Voleu inserir la contrasenya?", "sudoPasswordPopupHint": "Premeu Intro per inserir, Esc per tancar", "sudoPasswordPopupConfirm": "Insereix", @@ -1359,8 +1337,7 @@ "automaticFallback": "Intentant automàticament l'autenticació {{method}} ...", "totpTimeout": "Temps d'espera de verificació TOTP superat. Si us plau, torneu a connectar-vos.", "passwordTimeout": "Temps d'espera de verificació de la contrasenya. Si us plau, torneu a connectar-vos.", - "connectionRejected": "Connexió rebutjada pel servidor. Si us plau, comproveu l'autenticació i la configuració de xarxa.", - "hostKeyRejected": "Verificació de la clau de l'amfitrió SSH rebutjada. Connexió cancel·lada." + "connectionRejected": "Connexió rebutjada pel servidor. Si us plau, comproveu l'autenticació i la configuració de xarxa." }, "fileManager": { "title": "Gestor de fitxers", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Restabliment de la contrasenya correcte", "passwordResetSuccessDesc": "La teva contrasenya s'ha restablert correctament. Ara pots iniciar la sessió amb la nova contrasenya." }, - "hostKey": { - "verifyNewHost": "Verifica la clau d'amfitrió SSH", - "keyChangedWarning": "Clau d'amfitrió SSH canviada", - "firstConnectionTitle": "Primera vegada que em connecto a aquest amfitrió", - "firstConnectionDescription": "No es pot establir l'autenticitat d'aquest amfitrió. Verifiqueu que l'empremta digital coincideixi amb el que espereu.", - "keyChangedDescription": "La clau d'amfitrió d'aquest servidor ha canviat des de la vostra última connexió. Això podria indicar un problema de seguretat.", - "previousKey": "Clau anterior", - "newFingerprint": "Nova empremta digital", - "fingerprint": "Empremta digital", - "verifyInstructions": "Si confieu en aquest amfitrió, feu clic a Accepta per continuar i desar aquesta empremta digital per a futures connexions.", - "securityWarning": "Avís de seguretat", - "acceptAndContinue": "Accepta i continua", - "acceptNewKey": "Accepta la clau nova i continua" - }, "errors": { "notFound": "Pàgina no trobada", "unauthorized": "Accés no autoritzat", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Amfitrió i barra lateral", "snippetsSettings": "Fragments", - "confirmSnippetExecution": "Confirma l'execució del fragment", - "confirmSnippetExecutionDesc": "Mostra el diàleg de confirmació abans d'executar fragments", "updateSettings": "Actualitzacions", "disableUpdateCheck": "Desactiva la comprovació d'actualitzacions", "disableUpdateCheckDesc": "Deixa de comprovar si hi ha noves versions a l'inici i al tauler de control. Redueix les sol·licituds de xarxa.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 o example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/cs_CZ.json b/src/locales/translated/cs_CZ.json index 9216e997..6979349c 100644 --- a/src/locales/translated/cs_CZ.json +++ b/src/locales/translated/cs_CZ.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Pověření", "credentialsViewer": "Prohlížeč přihlašovacích údajů", "manageYourSSHCredentials": "Bezpečná správa přihlašovacích údajů SSH", "addCredential": "Přidat pověření", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Toto bezpečně přidá veřejný klíč do souboru ~/.ssh/authorized_keys bez přepsání existujících klíčů. Operace je vratná.", "chooseHostToDeploy": "Vyberte hostitele pro nasazení...", "deploying": "Nasazování...", - "copyDeployCommand": "Kopírovat příkaz pro nasazení", - "copyDeployCommandDescription": "Zkopírujte příkaz shell do manuálního přidání veřejného klíče do souboru autorized_keys. Užitečné, pokud je heslo vypnuto.", - "deployCommandCopied": "Příkaz nasazení zkopírován do schránky", - "manualDeployInfo": "Vložte tento příkaz do terminálu cílového hostitele (např. Proxmox konzola, IPMI nebo fyzický přístup) pro přidání SSH klíče.", "name": "Název", "noHostsAvailable": "Nejsou k dispozici žádní hostitelé", "noHostsMatchSearch": "Žádné hostitele neodpovídají vašemu hledání", @@ -303,9 +298,7 @@ "createFolder": "Vytvořit složku", "editFolder": "Upravit složku", "editFolderDescription": "Přizpůsobit složku snippetu", - "createFolderDescription": "Uspořádat úryvky do složek", - "confirmExecution": "Spustit \"{{name}}\"?", - "confirmExecutionDesc": "Jste si jisti, že chcete spustit tento snippet?" + "createFolderDescription": "Uspořádat úryvky do složek" }, "commandHistory": { "title": "Historie", @@ -409,7 +402,6 @@ "required": "Požadováno", "optional": "Nepovinné", "connect": "Připojit", - "copied": "Zkopírováno", "connecting": "Připojování...", "creating": "Vytváření...", "clear": "Vyčistit", @@ -502,7 +494,6 @@ "checking": "Kontroluje...", "checkingDatabase": "Kontrola připojení k databázi...", "checkingAuthentication": "Kontrola ověřování...", - "backendReconnected": "Připojení k serveru obnoveno", "actions": "Akce", "remove": "Odebrat", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Manažer serveru", - "hosts": "Hostitelé", "sshHosts": "SSH hostitelé", "noHosts": "Žádné SSH hostitele", "noHostsMessage": "Zatím jste nepřidali žádné SSH hostitele. Chcete-li začít, klikněte na \"Přidat hostitele\".", @@ -846,7 +836,7 @@ "failedToImportJson": "Nepodařilo se importovat soubor JSON", "connectionDetails": "Detaily připojení", "organization": "Organizace", - "ipAddress": "IP adresa nebo název hostitele", + "ipAddress": "IP adresa", "port": "Přístav", "name": "Název", "username": "Uživatelské jméno", @@ -925,8 +915,6 @@ "key": "Klíč", "credential": "Pověření", "none": "Nic", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Vyzývá vás k ověření webového serveru OPKSSH při každém připojení. Váš autentizační token bude trvat 24 hodin, dokud nebude nutné jej obnovit.", "selectCredential": "Vybrat pověření", "selectCredentialPlaceholder": "Zvolte přihlašovací údaje...", "credentialRequired": "Při použití ověření pověření je vyžadováno pověření", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Klávesnice – interaktivní ověření", "noneAuthDescription": "Tato metoda ověřování bude při připojování k serveru SSH používat klávesnice-interaktivní ověřování.", "noneAuthDetails": "Klávesnice - interaktivní autentizace umožňuje serveru požádat vás o přihlašovací údaje při připojení. Toto je užitečné pro servery, které vyžadují vícefázové autentifikaci, nebo pokud nechcete lokálně ukládat přihlašovací údaje.", - "opksshAuthTitle": "Ověření OPKSSH", "forceKeyboardInteractive": "Vynutit interaktivní klávesnici", "forceKeyboardInteractiveDesc": "Vynucuje použití klávesnice interaktivní autentizace. To je někdy nutné u serverů, které používají dvoufaktorové ověřování (TOTP/2FA).", "overrideCredentialUsername": "Přepsat pověření uživatelské jméno", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Otevřít v prohlížeči", "warpgateContinue": "Dokončil jsem ověření", "warpgateTimeout": "Časový limit autentizace Warpgate vypršel. Připojte se.", - "opksshAuthRequired": "Vyžadováno ověření OPKSSH", - "opksshAuthDescription": "Dokončete ověření ve vašem prohlížeči, abyste mohli pokračovat. Tato relace zůstane v platnosti 24 hodin.", - "opksshAuthUrl": "Ověřovací URL", - "opksshOpenBrowser": "Otevřete prohlížeč pro ověření", - "opksshWaitingForAuth": "Čekání na ověřování v prohlížeči...", - "opksshAuthenticating": "Zpracovávání ověřování...", - "opksshTimeout": "Vypršel časový limit ověření. Zkuste to prosím znovu.", - "opksshAuthFailed": "Ověření se nezdařilo. Zkontrolujte prosím vaše přihlašovací údaje a zkuste to znovu.", - "opksshConfigMissing": "Nastavení OPKSSH nebylo nalezeno. Vytvořte prosím ~/.opk/config.yml s nastavením OIDC poskytovatele. Viz dokumentace: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Vložit heslo?", "sudoPasswordPopupHint": "Stiskněte Enter pro vložení, Esc pro zamítnutí", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Automaticky zkouším {{method}} autentizaci...", "totpTimeout": "Časový limit ověřování TOTP. Připojte se znovu.", "passwordTimeout": "Časový limit ověření hesla vypršel. Připojte se znovu.", - "connectionRejected": "Připojení odmítnuto serverem. Zkontrolujte prosím ověření a nastavení sítě.", - "hostKeyRejected": "Ověření SSH hostitele bylo zamítnuto. Připojení bylo zrušeno." + "connectionRejected": "Připojení odmítnuto serverem. Zkontrolujte prosím ověření a nastavení sítě." }, "fileManager": { "title": "Správce souborů", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Obnovení hesla úspěšné", "passwordResetSuccessDesc": "Vaše heslo bylo úspěšně obnoveno. Nyní se můžete přihlásit svým novým heslem." }, - "hostKey": { - "verifyNewHost": "Ověřit klíč SSH hostitele", - "keyChangedWarning": "SSH klíč hostitele změněn", - "firstConnectionTitle": "První připojení k tomuto hostiteli", - "firstConnectionDescription": "Nelze určit pravost tohoto hostitele. Ověřte shodu s tím, co očekáváte.", - "keyChangedDescription": "Hostitelský klíč pro tento server se změnil od vašeho posledního připojení. To by mohlo znamenat bezpečnostní problém.", - "previousKey": "Předchozí klíč", - "newFingerprint": "Nový otisk prstu", - "fingerprint": "Otisk prstu", - "verifyInstructions": "Pokud tomuto hostiteli důvěřujete, klepněte na tlačítko Přijmout pro pokračování a uložení tohoto otisku pro budoucí připojení.", - "securityWarning": "Bezpečnostní varování", - "acceptAndContinue": "Přijmout a pokračovat", - "acceptNewKey": "Přijmout nový klíč a pokračovat" - }, "errors": { "notFound": "Stránka nenalezena", "unauthorized": "Neoprávněný přístup", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminál", "hostSidebarSettings": "Hostitel & postranní panel", "snippetsSettings": "Výstřižky bloků", - "confirmSnippetExecution": "Potvrdit spuštění snippetu", - "confirmSnippetExecutionDesc": "Zobrazit potvrzovací dialog před spuštěním snippetů", "updateSettings": "Aktualizace", "disableUpdateCheck": "Zakázat kontrolu aktualizací", "disableUpdateCheckDesc": "Zastavit kontrolu nových verzí na startu a řídicím panelu. Sníží síťové požadavky.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 nebo příklad.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/da_DK.json b/src/locales/translated/da_DK.json index aeb9ef55..79dc8190 100644 --- a/src/locales/translated/da_DK.json +++ b/src/locales/translated/da_DK.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Legitimation", "credentialsViewer": "Legitimationsfremviser", "manageYourSSHCredentials": "Administrer dine SSH-akkreditiver sikkert", "addCredential": "Tilføj Credential", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Dette vil sikkert tilføje den offentlige nøgle til målet værtens ~/.ssh / authorized_keys fil uden at overskrive eksisterende nøgler. Handlingen er reversibel.", "chooseHostToDeploy": "Vælg en vært at implementere til...", "deploying": "Implementerer...", - "copyDeployCommand": "Kopier Implementeringskommando", - "copyDeployCommandDescription": "Kopiér en skal- kommando for manuelt at tilføje den offentlige nøgle til en værts autoriseret _ keys- fil. Nyttigt når adgangskodegodkendelse er deaktiveret.", - "deployCommandCopied": "Deploy kommando kopieret til udklipsholder", - "manualDeployInfo": "Indsæt denne kommando i målet værtens terminal (fx, Proxmox konsol, IPMI, eller fysisk adgang) for at tilføje SSH-nøglen.", "name": "Navn", "noHostsAvailable": "Ingen værter tilgængelige", "noHostsMatchSearch": "Ingen værter matcher din søgning", @@ -303,9 +298,7 @@ "createFolder": "Opret Mappe", "editFolder": "Rediger Mappe", "editFolderDescription": "Tilpas din snippet mappe", - "createFolderDescription": "Organiser dine snippets i mapper", - "confirmExecution": "Kør \"{{name}}\"?", - "confirmExecutionDesc": "Er du sikker på, at du vil udføre denne snippet?" + "createFolderDescription": "Organiser dine snippets i mapper" }, "commandHistory": { "title": "Historik", @@ -409,7 +402,6 @@ "required": "Påkrævet", "optional": "Valgfri", "connect": "Forbind", - "copied": "Kopieret", "connecting": "Forbinder...", "creating": "Opretter...", "clear": "Ryd", @@ -502,7 +494,6 @@ "checking": "Kontrollerer...", "checkingDatabase": "Kontrollerer databaseforbindelse...", "checkingAuthentication": "Kontrollerer godkendelse...", - "backendReconnected": "Server forbindelse gendannet", "actions": "Handlinger", "remove": "Fjern", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Vært Manager", - "hosts": "Værter", "sshHosts": "SSH Værter", "noHosts": "Ingen SSH værter", "noHostsMessage": "Du har endnu ikke tilføjet nogen SSH-værter. Klik på \"Tilføj vært\" for at komme i gang.", @@ -846,7 +836,7 @@ "failedToImportJson": "Kunne ikke importere JSON- fil", "connectionDetails": "Forbindelse Detaljer", "organization": "Organisation", - "ipAddress": "Ip adresse eller værtsnavn", + "ipAddress": "Ip Adresse", "port": "Port", "name": "Navn", "username": "Brugernavn", @@ -925,8 +915,6 @@ "key": "Nøgle", "credential": "Credential", "none": "Ingen", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Spørg dig om OPKSSH web-auth ved hver forbindelse. Din auth token vil vare i 24 timer, indtil den skal fornyes.", "selectCredential": "Vælg Credential", "selectCredentialPlaceholder": "Vælg en legitimationsoplysning...", "credentialRequired": "Oplysninger er påkrævet, når du bruger legitimationsoplysninger godkendelse", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Tastatur-Interaktiv Autentificering", "noneAuthDescription": "Denne godkendelsesmetode vil bruge tastatur-interaktiv godkendelse, når der oprettes forbindelse til SSH-serveren.", "noneAuthDetails": "Tastatur-interaktiv godkendelse tillader serveren at bede dig om legitimationsoplysninger under forbindelsen. Dette er nyttigt for servere, der kræver multi-faktor godkendelse, eller hvis du ikke ønsker at gemme legitimationsoplysninger lokalt.", - "opksshAuthTitle": "OPKSSH- Godkendelse", "forceKeyboardInteractive": "Tving Tastatur-Interaktiv", "forceKeyboardInteractiveDesc": "Kræver brugen af tastatur- interaktiv godkendelse. Dette kræves sommetider for servere, der bruger to- faktor godkendelse (TOTP/2FA).", "overrideCredentialUsername": "Tilsidesæt Credential Brugernavn", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Åbn i browser", "warpgateContinue": "Jeg Er Afsluttet Godkendelse", "warpgateTimeout": "Warpgate authentication timeout. Genforbind.", - "opksshAuthRequired": "OPKSSH Godkendelse Kræves", - "opksshAuthDescription": "Komplet godkendelse i din browser for at fortsætte. Denne session vil forblive gyldig i 24 timer.", - "opksshAuthUrl": "Godkendelses-URL", - "opksshOpenBrowser": "Åbn browser for at godkende", - "opksshWaitingForAuth": "Venter på godkendelse i browser...", - "opksshAuthenticating": "Behandler godkendelse...", - "opksshTimeout": "Godkendelse fik timeout. Prøv venligst igen.", - "opksshAuthFailed": "Godkendelse mislykkedes. Kontroller dine legitimationsoplysninger og prøv igen.", - "opksshConfigMissing": "OPKSSH konfiguration ikke fundet. Opret venligst ~/.opk/config.yml med dine OIDC udbyder indstillinger. Se dokumentation: https://github.com/openpubkey/opkssh#konfiguration", "sudoPasswordPopupTitle": "Indsæt Adgangskode?", "sudoPasswordPopupHint": "Tryk på Enter for at indsætte, Esc for at afvise", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Prøver automatisk {{method}} autentificering...", "totpTimeout": "TOTP bekræftelses-timeout. Genforbind.", "passwordTimeout": "Adgangskodens bekræftelses-timeout. Opret venligst ny forbindelse.", - "connectionRejected": "Forbindelse afvist af serveren. Tjek venligst din godkendelse og netværkskonfiguration.", - "hostKeyRejected": "SSH-værtnøglebekræftelse afvist. Forbindelse annulleret." + "connectionRejected": "Forbindelse afvist af serveren. Tjek venligst din godkendelse og netværkskonfiguration." }, "fileManager": { "title": "Filhåndtering", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Adgangskode Nulstilling Lykkedes", "passwordResetSuccessDesc": "Din adgangskode er blevet nulstillet. Du kan nu logge ind med din nye adgangskode." }, - "hostKey": { - "verifyNewHost": "Verificér SSH-værtnøgle", - "keyChangedWarning": "SSH Værtsnøgle Ændret", - "firstConnectionTitle": "Første gang der oprettes forbindelse til værten", - "firstConnectionDescription": "Denne værts ægthed kan ikke fastslås. Kontrollér, at fingeraftrykket matcher det, du forventer.", - "keyChangedDescription": "Værtsnøglen for denne server er ændret siden din sidste forbindelse. Dette kan indikere et sikkerhedsproblem.", - "previousKey": "Forrige Nøgle", - "newFingerprint": "Nyt Fingeraftryk", - "fingerprint": "Fingeraftryk", - "verifyInstructions": "Hvis du stoler på denne vært, skal du klikke på Accepter for at fortsætte og gemme dette fingeraftryk til fremtidige forbindelser.", - "securityWarning": "Advarsel Om Sikkerhed", - "acceptAndContinue": "Accepter & Fortsæt", - "acceptNewKey": "Accepter Ny Nøgle Og Fortsæt" - }, "errors": { "notFound": "Siden blev ikke fundet", "unauthorized": "Uautoriseret adgang", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Vært & Sidebjælke", "snippetsSettings": "Stumper", - "confirmSnippetExecution": "Bekræft Udførelse Af Snippet", - "confirmSnippetExecutionDesc": "Vis bekræftelsesdialog før udførelse af snippets", "updateSettings": "Opdateringer", "disableUpdateCheck": "Deaktivér Opdateringstjek", "disableUpdateCheckDesc": "Stop med at kontrollere for nye versioner ved opstart og dashboard. Reducerer netværksanmodninger.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 eller eksempel.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/de_DE.json b/src/locales/translated/de_DE.json index bed720a5..2a7d95f4 100644 --- a/src/locales/translated/de_DE.json +++ b/src/locales/translated/de_DE.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Anmeldedaten", "credentialsViewer": "Zugangsdaten-Betrachter", "manageYourSSHCredentials": "SSH Zugangsdaten sicher verwalten", "addCredential": "Zugangsdaten hinzufügen", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Dies fügt den öffentlichen Schlüssel sicher zur Datei ~/.ssh/authorized_keys des Zielservers hinzu, ohne vorhandene Schlüssel zu überschreiben. Die Operation ist reversibel.", "chooseHostToDeploy": "Wählen Sie einen Host für den sie bereitstellen soll...", "deploying": "Verteilen...", - "copyDeployCommand": "Deploy-Befehl kopieren", - "copyDeployCommandDescription": "Kopieren Sie einen Shell-Befehl, um den öffentlichen Schlüssel manuell zu der Datei authorized_keys eines Hosts hinzuzufügen. Nützlich, wenn die Passwort-Authentifizierung deaktiviert ist.", - "deployCommandCopied": "Deploy-Befehl in Zwischenablage kopiert", - "manualDeployInfo": "Fügen Sie diesen Befehl in das Terminal des Ziel-Hosts ein (z.B. Proxmox-Konsole, IPMI oder physikalischer Zugang), um den SSH-Schlüssel hinzuzufügen.", "name": "Name", "noHostsAvailable": "Keine Hosts verfügbar", "noHostsMatchSearch": "Keine Hosts entsprechen Ihrer Suche", @@ -303,9 +298,7 @@ "createFolder": "Ordner erstellen", "editFolder": "Ordner bearbeiten", "editFolderDescription": "Passen Sie Ihren Snippet Ordner an", - "createFolderDescription": "Organisieren Sie Ihre Snippets in Ordner", - "confirmExecution": "\"{{name}} \"ausführen?", - "confirmExecutionDesc": "Sind Sie sicher, dass Sie dieses Snippet ausführen möchten?" + "createFolderDescription": "Organisieren Sie Ihre Snippets in Ordner" }, "commandHistory": { "title": "Verlauf", @@ -409,7 +402,6 @@ "required": "Benötigt", "optional": "Optional", "connect": "Verbinden", - "copied": "Kopiert", "connecting": "Verbinden...", "creating": "Erstellen...", "clear": "Leeren", @@ -502,7 +494,6 @@ "checking": "Überprüfen...", "checkingDatabase": "Überprüfe Datenbankverbindung...", "checkingAuthentication": "Authentifizierung wird überprüft...", - "backendReconnected": "Serververbindung wiederhergestellt", "actions": "Aktionen", "remove": "Entfernen", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Host-Manager", - "hosts": "Hosts", "sshHosts": "SSH-Hosts", "noHosts": "Keine SSH-Hosts", "noHostsMessage": "Sie haben noch keine SSH-Hosts hinzugefügt. Klicken Sie auf \"Host hinzufügen\", um loszulegen.", @@ -846,7 +836,7 @@ "failedToImportJson": "Import der JSON-Datei fehlgeschlagen", "connectionDetails": "Verbindungsdetails", "organization": "Organisation", - "ipAddress": "IP-Adresse oder Hostname", + "ipAddress": "IP-Adresse", "port": "Port", "name": "Name", "username": "Benutzername", @@ -925,8 +915,6 @@ "key": "Schlüssel", "credential": "Anmeldedaten", "none": "Keine", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Fordern Sie bei jeder Verbindung nach OPKSSH web-auth an. Ihr Auth-Token hält 24 Stunden an, bis es erneuert werden muss.", "selectCredential": "Anmeldeinformationen auswählen", "selectCredentialPlaceholder": "Anmeldedaten auswählen...", "credentialRequired": "Anmeldeinformationen werden benötigt, wenn die Anmeldeinformationen verwendet werden", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Tastatur-Interaktive Authentifizierung", "noneAuthDescription": "Diese Authentifizierungsmethode verwendet die Tastatur-interaktive Authentifizierung bei der Verbindung zum SSH-Server.", "noneAuthDetails": "Die interaktive Keyboard-Authentifizierung erlaubt es dem Server, Sie während der Verbindung nach Anmeldeinformationen zu fragen. Dies ist nützlich für Server, die Multi-Faktor-Authentifizierung benötigen oder wenn Sie nicht lokal Anmeldedaten speichern möchten.", - "opksshAuthTitle": "OPKSSH Authentifizierung", "forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen", "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung von Tastatur-interaktiver Authentifizierung. Dies wird manchmal für Server benötigt, die eine Zwei-Faktor-Authentifizierung verwenden (TOTP/2FA).", "overrideCredentialUsername": "Benutzername überschreiben", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Im Browser öffnen", "warpgateContinue": "Ich habe die Authentifizierung abgeschlossen", "warpgateTimeout": "Warpgate Authentifizierungs-Timeout. Bitte verbinden Sie sich erneut.", - "opksshAuthRequired": "OPKSSH Authentifizierung erforderlich", - "opksshAuthDescription": "Vollständige Authentifizierung in Ihrem Browser, um fortzufahren. Diese Sitzung bleibt 24 Stunden gültig.", - "opksshAuthUrl": "Authentifizierungs-URL", - "opksshOpenBrowser": "Browser zum Authentifizieren öffnen", - "opksshWaitingForAuth": "Warte auf Authentifizierung im Browser...", - "opksshAuthenticating": "Authentifizierung wird verarbeitet...", - "opksshTimeout": "Timeout der Authentifizierung. Bitte versuchen Sie es erneut.", - "opksshAuthFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten und versuchen Sie es erneut.", - "opksshConfigMissing": "OPKSSH Konfiguration nicht gefunden. Bitte erstellen Sie ~/.opk/config.yml mit Ihren OIDC Providereinstellungen. Siehe Dokumentation: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Passwort einfügen?", "sudoPasswordPopupHint": "Drücken Sie die Eingabetaste, Esc zum Verwerfen", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Automatisch {{method}} Authentifizierung...", "totpTimeout": "TOTP-Verifizierungs-Timeout. Bitte erneut verbinden.", "passwordTimeout": "Timeout zur Passwortüberprüfung. Bitte erneut verbinden.", - "connectionRejected": "Verbindung vom Server abgelehnt. Bitte überprüfen Sie Ihre Authentifizierung und Netzwerkkonfiguration.", - "hostKeyRejected": "Überprüfung des SSH-Host-Schlüssels abgelehnt. Verbindung abgebrochen." + "connectionRejected": "Verbindung vom Server abgelehnt. Bitte überprüfen Sie Ihre Authentifizierung und Netzwerkkonfiguration." }, "fileManager": { "title": "Datei-Manager", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Passwort erfolgreich zurückgesetzt", "passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden." }, - "hostKey": { - "verifyNewHost": "SSH-Hostschlüssel überprüfen", - "keyChangedWarning": "SSH-Hostschlüssel geändert", - "firstConnectionTitle": "Verbindung zu diesem Host zum ersten Mal", - "firstConnectionDescription": "Die Authentizität dieses Hosts kann nicht festgestellt werden. Überprüfen Sie den Fingerabdruck mit dem, was Sie erwarten.", - "keyChangedDescription": "Der Host-Schlüssel für diesen Server hat sich seit Ihrer letzten Verbindung geändert. Dies könnte auf ein Sicherheitsproblem hinweisen.", - "previousKey": "Vorheriger Schlüssel", - "newFingerprint": "Neuer Fingerabdruck", - "fingerprint": "Fingerabdruck", - "verifyInstructions": "Wenn Sie diesem Host vertrauen, klicken Sie auf Akzeptieren, um fortzufahren und den Fingerabdruck für zukünftige Verbindungen zu speichern.", - "securityWarning": "Sicherheitswarnung", - "acceptAndContinue": "Akzeptieren & fortfahren", - "acceptNewKey": "Neuen Schlüssel akzeptieren & fortfahren" - }, "errors": { "notFound": "Seite nicht gefunden", "unauthorized": "Unberechtigter Zugriff", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Host & Seitenleiste", "snippetsSettings": "Schnipsel", - "confirmSnippetExecution": "Snippet Ausführung bestätigen", - "confirmSnippetExecutionDesc": "Bestätigungsdialog vor dem Ausführen von Snippets anzeigen", "updateSettings": "Updates", "disableUpdateCheck": "Updateprüfung deaktivieren", "disableUpdateCheckDesc": "Stoppt die Suche nach neuen Versionen beim Start und Dashboard. Reduziert Netzwerkanfragen.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 oder beispiel.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/el_GR.json b/src/locales/translated/el_GR.json index 8f474b83..722820f6 100644 --- a/src/locales/translated/el_GR.json +++ b/src/locales/translated/el_GR.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Διαπιστευτήρια", "credentialsViewer": "Προβολέας Πιστοποιητικών", "manageYourSSHCredentials": "Διαχειριστείτε τα SSH διαπιστευτήρια σας με ασφάλεια", "addCredential": "Προσθήκη Διαπιστευτηρίου", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Αυτό θα προσθέσει με ασφάλεια το δημόσιο κλειδί στο αρχείο ~/.ssh/authorized_keys του κεντρικού υπολογιστή του στόχου χωρίς να αντικαταστήσει τα υπάρχοντα κλειδιά. Η λειτουργία είναι αναστρέψιμη.", "chooseHostToDeploy": "Επιλέξτε έναν κεντρικό υπολογιστή για ανάπτυξη...", "deploying": "Εφαρμογή...", - "copyDeployCommand": "Αντιγραφή Εφαρμογής Εντολής", - "copyDeployCommandDescription": "Αντιγράψτε μια εντολή κελύφους για να προσθέσετε χειροκίνητα το δημόσιο κλειδί στο αρχείο authorized_keys ενός κεντρικού υπολογιστή. Χρήσιμο όταν η ταυτοποίηση κωδικού πρόσβασης είναι απενεργοποιημένη.", - "deployCommandCopied": "Η εντολή ανάπτυξης αντιγράφηκε στο πρόχειρο", - "manualDeployInfo": "Επικολλήστε αυτήν την εντολή στο τερματικό του κεντρικού υπολογιστή προορισμού (π.χ., κονσόλα Proxmox, IPMI ή φυσική πρόσβαση) για να προσθέσετε το SSH κλειδί.", "name": "Όνομα", "noHostsAvailable": "Δεν υπάρχουν διαθέσιμοι υπολογιστές", "noHostsMatchSearch": "Κανένας υπολογιστής δεν ταιριάζει με την αναζήτησή σας", @@ -303,9 +298,7 @@ "createFolder": "Δημιουργία Φακέλου", "editFolder": "Επεξεργασία Φακέλου", "editFolderDescription": "Προσαρμόστε το φάκελο αποκοπής", - "createFolderDescription": "Οργανώστε τα αποσπάσματα σας σε φακέλους", - "confirmExecution": "Εκτέλεση \"{{name}}\"?", - "confirmExecutionDesc": "Είστε βέβαιοι ότι θέλετε να εκτελέσετε αυτό το απόφραξη?" + "createFolderDescription": "Οργανώστε τα αποσπάσματα σας σε φακέλους" }, "commandHistory": { "title": "Ιστορικό", @@ -409,7 +402,6 @@ "required": "Απαιτείται", "optional": "Προαιρετικό", "connect": "Σύνδεση", - "copied": "Αντιγράφηκε", "connecting": "Σύνδεση...", "creating": "Δημιουργία...", "clear": "Εκκαθάριση", @@ -502,7 +494,6 @@ "checking": "Έλεγχος...", "checkingDatabase": "Έλεγχος σύνδεσης βάσης δεδομένων...", "checkingAuthentication": "Έλεγχος ταυτοποίησης...", - "backendReconnected": "Έγινε επαναφορά σύνδεσης διακομιστή", "actions": "Ενέργειες", "remove": "Αφαίρεση", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Διαχειριστής Υπολογιστών", - "hosts": "Υπολογιστές", "sshHosts": "SSH Hosts", "noHosts": "Δεν Υπάρχουν Υπολογιστές SSH", "noHostsMessage": "Δεν έχετε προσθέσει ακόμα κανένα SSH hosts. Κάντε κλικ στο κουμπί \"Προσθήκη Host\" για να ξεκινήσετε.", @@ -846,7 +836,7 @@ "failedToImportJson": "Αποτυχία εισαγωγής αρχείου JSON", "connectionDetails": "Λεπτομέρειες Σύνδεσης", "organization": "Οργανισμός", - "ipAddress": "Διεύθυνση IP ή όνομα κεντρικού υπολογιστή", + "ipAddress": "Διεύθυνση IP", "port": "Θύρα", "name": "Όνομα", "username": "Όνομα Χρήστη", @@ -925,8 +915,6 @@ "key": "Κλειδί", "credential": "Διαπιστευτήριο", "none": "Κανένα", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Σας παρακινεί για την ταυτοποίηση ιστού OPKSSH σε κάθε σύνδεση. Το διακριτικό ταυτοποίησης θα διαρκέσει 24 ώρες μέχρι να ανανεωθεί.", "selectCredential": "Επιλέξτε Διαπιστευτήρια", "selectCredentialPlaceholder": "Επιλέξτε ένα πιστοποιητικό...", "credentialRequired": "Απαιτείται διαπιστευτήριο όταν χρησιμοποιείται έλεγχος ταυτότητας διαπιστευτηρίων", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Πληκτρολόγιο-Διαδραστική Ταυτοποίηση", "noneAuthDescription": "Αυτή η μέθοδος ταυτοποίησης θα χρησιμοποιεί τον διαδραστικό έλεγχο ταυτότητας πληκτρολογίου κατά τη σύνδεση με τον διακομιστή SSH.", "noneAuthDetails": "Ο διαδραστικός έλεγχος ταυτότητας πληκτρολογίου επιτρέπει στο διακομιστή να σας ζητά διαπιστευτήρια κατά τη διάρκεια της σύνδεσης. Αυτό είναι χρήσιμο για διακομιστές που απαιτούν έλεγχο ταυτότητας πολλαπλών παραγόντων ή αν δεν θέλετε να αποθηκεύσετε τα διαπιστευτήρια τοπικά.", - "opksshAuthTitle": "Επαλήθευση OPKSSH", "forceKeyboardInteractive": "Εξαναγκασμός Keyboard-Interactive", "forceKeyboardInteractiveDesc": "Δυνάμει της χρήσης διαδραστικού ελέγχου ταυτότητας πληκτρολογίου. Αυτό μερικές φορές απαιτείται για διακομιστές που χρησιμοποιούν έλεγχο ταυτότητας δύο παραγόντων (TOTP/2FA).", "overrideCredentialUsername": "Παράκαμψη Ονόματος Διαπιστευτηρίου Χρήστη", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Άνοιγμα σε περιηγητή", "warpgateContinue": "Έχω Ολοκληρώσει την Πιστοποίηση", "warpgateTimeout": "Λήξη χρονικού ορίου ταυτοποίησης. Παρακαλώ επανασύνδεση.", - "opksshAuthRequired": "Απαιτείται Πιστοποίηση OPKSSH", - "opksshAuthDescription": "Πλήρης ταυτοποίηση στο πρόγραμμα περιήγησής σας για να συνεχίσετε. Αυτή η συνεδρία θα παραμείνει έγκυρη για 24 ώρες.", - "opksshAuthUrl": "URL Ταυτοποίησης", - "opksshOpenBrowser": "Άνοιγμα περιηγητή για έλεγχο ταυτότητας", - "opksshWaitingForAuth": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...", - "opksshAuthenticating": "Επεξεργασία ταυτοποίησης...", - "opksshTimeout": "Λήξη χρονικού ορίου επαλήθευσης. Παρακαλώ προσπαθήστε ξανά.", - "opksshAuthFailed": "Ο έλεγχος ταυτότητας απέτυχε. Παρακαλώ ελέγξτε τα στοιχεία σας και προσπαθήστε ξανά.", - "opksshConfigMissing": "Η ρύθμιση παραμέτρων OPKSSH δεν βρέθηκε. Παρακαλώ δημιουργήστε ~/.opk/config.yml με τις ρυθμίσεις παρόχου OIDC. Δείτε την τεκμηρίωση: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Εισαγωγή Κωδικού Πρόσβασης?", "sudoPasswordPopupHint": "Πατήστε Enter για εισαγωγή, Esc για απόρριψη", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Αυτόματη προσπάθεια ελέγχου ταυτότητας {{method}}...", "totpTimeout": "Λήξη χρονικού ορίου επαλήθευσης TOTP. Επανασύνδεση.", "passwordTimeout": "Λήξη χρονικού ορίου επαλήθευσης κωδικού πρόσβασης. Επανασύνδεση.", - "connectionRejected": "Η σύνδεση απορρίφθηκε από το διακομιστή. Παρακαλώ ελέγξτε τον έλεγχο ταυτότητας και τις ρυθμίσεις δικτύου.", - "hostKeyRejected": "Η επαλήθευση SSH κεντρικού υπολογιστή απορρίφθηκε. Η σύνδεση ακυρώθηκε." + "connectionRejected": "Η σύνδεση απορρίφθηκε από το διακομιστή. Παρακαλώ ελέγξτε τον έλεγχο ταυτότητας και τις ρυθμίσεις δικτύου." }, "fileManager": { "title": "Διαχειριστής Αρχείων", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Επιτυχής Επαναφορά Κωδικού Πρόσβασης", "passwordResetSuccessDesc": "Επιτυχής επαναφορά του κωδικού πρόσβασης. Μπορείτε τώρα να συνδεθείτε με το νέο κωδικό πρόσβασης." }, - "hostKey": { - "verifyNewHost": "Επαλήθευση Κλειδιού Διακομιστή SSH", - "keyChangedWarning": "Το Κλειδί Υπολογιστή SSH Άλλαξε", - "firstConnectionTitle": "Πρώτη σύνδεση σε αυτόν τον υπολογιστή", - "firstConnectionDescription": "Η αυθεντικότητα αυτού του κεντρικού υπολογιστή δεν μπορεί να καθοριστεί. Βεβαιωθείτε ότι το δακτυλικό αποτύπωμα ταιριάζει με αυτό που περιμένετε.", - "keyChangedDescription": "Το κλειδί φιλοξενίας αυτού του διακομιστή έχει αλλάξει από την τελευταία σας σύνδεση. Αυτό μπορεί να υποδηλώνει ένα πρόβλημα ασφάλειας.", - "previousKey": "Προηγούμενο Κλειδί", - "newFingerprint": "Νέο Δακτυλικό Αποτύπωμα", - "fingerprint": "Αποτύπωμα", - "verifyInstructions": "Αν εμπιστεύεστε αυτόν τον οικοδεσπότη, κάντε κλικ για να συνεχίσετε και να αποθηκεύσετε αυτό το δακτυλικό αποτύπωμα για μελλοντικές συνδέσεις.", - "securityWarning": "Προειδοποίηση Ασφαλείας", - "acceptAndContinue": "Αποδοχή & Συνέχεια", - "acceptNewKey": "Αποδοχή Νέου Κλειδιού & Συνέχεια" - }, "errors": { "notFound": "Η σελίδα δεν βρέθηκε", "unauthorized": "Μη εξουσιοδοτημένη πρόσβαση", @@ -2063,8 +2026,6 @@ "terminalSettings": "Τερματικό", "hostSidebarSettings": "Κεντρικός Υπολογιστής & Πλαϊνή Μπάρα", "snippetsSettings": "Δείγματα", - "confirmSnippetExecution": "Επιβεβαίωση Εκτέλεσης Δείκτη", - "confirmSnippetExecutionDesc": "Εμφάνιση διαλόγου επιβεβαίωσης πριν την εκτέλεση αποσπασμάτων", "updateSettings": "Ενημερώσεις", "disableUpdateCheck": "Απενεργοποίηση Ελέγχου Ενημέρωσης", "disableUpdateCheckDesc": "Διακοπή ελέγχου για νέες εκδόσεις κατά την εκκίνηση και τον πίνακα ελέγχου. Μειώνει τα αιτήματα δικτύου.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 ή example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/es_ES.json b/src/locales/translated/es_ES.json index 3a60502f..c807924e 100644 --- a/src/locales/translated/es_ES.json +++ b/src/locales/translated/es_ES.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Credenciales", "credentialsViewer": "Visor de credenciales", "manageYourSSHCredentials": "Administrar sus credenciales SSH de forma segura", "addCredential": "Añadir credencial", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Esto añadirá con seguridad la clave pública al archivo ~/.ssh/authorized_keys del host de destino sin sobrescribir las claves existentes. La operación es reversible.", "chooseHostToDeploy": "Seleccione un host al que desplegar...", "deploying": "Desplegando...", - "copyDeployCommand": "Copiar comando de despliegue", - "copyDeployCommandDescription": "Copie un comando de shell para agregar manualmente la clave pública al archivo authorized_keys de un host. Útil cuando la autenticación de contraseña está desactivada.", - "deployCommandCopied": "Desplegar comando copiado al portapapeles", - "manualDeployInfo": "Pegue este comando en el terminal del host de destino (por ejemplo, consola Proxmox, IPMI o acceso físico) para añadir la clave SSH.", "name": "Nombre", "noHostsAvailable": "No hay hosts disponibles", "noHostsMatchSearch": "Ningún host coincide con tu búsqueda", @@ -303,9 +298,7 @@ "createFolder": "Crear carpeta", "editFolder": "Editar carpeta", "editFolderDescription": "Personaliza tu carpeta de fragmentos", - "createFolderDescription": "Organiza tus fragmentos en carpetas", - "confirmExecution": "¿Ejecutar \"{{name}}\"?", - "confirmExecutionDesc": "¿Está seguro de que desea ejecutar este snippet?" + "createFolderDescription": "Organiza tus fragmentos en carpetas" }, "commandHistory": { "title": "Historial", @@ -409,7 +402,6 @@ "required": "Requerido", "optional": "Opcional", "connect": "Conectar", - "copied": "Copiado", "connecting": "Conectando...", "creating": "Creando...", "clear": "Claro", @@ -502,7 +494,6 @@ "checking": "Comprobando...", "checkingDatabase": "Comprobando conexión a la base de datos...", "checkingAuthentication": "Comprobando autenticación...", - "backendReconnected": "Conexión con el servidor restaurada", "actions": "Acciones", "remove": "Eliminar", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gestor de Anfitriones", - "hosts": "Anfitriones", "sshHosts": "Equipos SSH", "noHosts": "No hay hosts SSH", "noHostsMessage": "Aún no has añadido ningún host SSH. Haz clic en \"Añadir Host\" para empezar.", @@ -846,7 +836,7 @@ "failedToImportJson": "Error al importar el archivo JSON", "connectionDetails": "Detalles de la conexión", "organization": "Organización", - "ipAddress": "Dirección IP o nombre de host", + "ipAddress": "Dirección IP", "port": "Puerto", "name": "Nombre", "username": "Usuario", @@ -925,8 +915,6 @@ "key": "Clave", "credential": "Credencial", "none": "Ninguna", - "opkssh": "OPKSH", - "opksshAuthDescription": "Te solicita la autenticación web de OPKSSH en cada conexión. Tu token de autenticación durará 24 horas hasta que deba ser renovado.", "selectCredential": "Seleccionar credencial", "selectCredentialPlaceholder": "Elija una credencial...", "credentialRequired": "Se requiere credencial cuando se utiliza autenticación de credenciales", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autenticación interactiva del teclado", "noneAuthDescription": "Este método de autenticación utilizará la autenticación interactiva del teclado cuando se conecte al servidor SSH.", "noneAuthDetails": "La autenticación interactiva de teclado permite al servidor pedir credenciales durante la conexión. Esto es útil para servidores que requieren autenticación de múltiples factores o si no desea guardar las credenciales localmente.", - "opksshAuthTitle": "Autenticación OPKSSH", "forceKeyboardInteractive": "Forzar interactivo del teclado", "forceKeyboardInteractiveDesc": "Fuerza el uso de autenticación interactiva de teclado. Esto a veces es necesario para servidores que utilizan Autenticación de Doble Factor (TOTP/2FA).", "overrideCredentialUsername": "Reemplazar nombre de usuario de credenciales", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Abrir en el navegador", "warpgateContinue": "He completado la autenticación", "warpgateTimeout": "Se agotó el tiempo de autenticación de Warpgate. Por favor, vuelva a conectar.", - "opksshAuthRequired": "Autenticación OPKSSH requerida", - "opksshAuthDescription": "Autenticación completa en su navegador para continuar. Esta sesión permanecerá válida durante 24 horas.", - "opksshAuthUrl": "URL de autenticación", - "opksshOpenBrowser": "Abrir navegador para autenticar", - "opksshWaitingForAuth": "Esperando la autenticación en el navegador...", - "opksshAuthenticating": "Procesando autenticación...", - "opksshTimeout": "Se ha agotado el tiempo de autenticación. Por favor, inténtalo de nuevo.", - "opksshAuthFailed": "Error de autenticación. Por favor, comprueba tus credenciales e inténtalo de nuevo.", - "opksshConfigMissing": "Configuración OPKSSH no encontrada. Por favor cree ~/.opk/config.yml con la configuración de su proveedor OIDC. Vea la documentación: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "¿Insertar contraseña?", "sudoPasswordPopupHint": "Presione Enter para insertar, Esc para descartar", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Intentando automáticamente autenticación {{method}}...", "totpTimeout": "Tiempo de verificación TOTP. Por favor, vuelva a conectar.", "passwordTimeout": "Tiempo de verificación de contraseña. Por favor, vuelva a conectar.", - "connectionRejected": "Conexión rechazada por el servidor. Por favor, compruebe su autenticación y configuración de red.", - "hostKeyRejected": "Verificación de clave del host SSH rechazada. Conexión cancelada." + "connectionRejected": "Conexión rechazada por el servidor. Por favor, compruebe su autenticación y configuración de red." }, "fileManager": { "title": "Gestor de archivos", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Contraseña restablecida con éxito", "passwordResetSuccessDesc": "Su contraseña se ha restablecido con éxito. Ahora puede iniciar sesión con su nueva contraseña." }, - "hostKey": { - "verifyNewHost": "Verificar clave SSH Host", - "keyChangedWarning": "SSH Host Key cambiada", - "firstConnectionTitle": "Primera conexión a este host", - "firstConnectionDescription": "La autenticidad de este host no se puede establecer. Verifique que la huella digital coincide con lo que espera.", - "keyChangedDescription": "La clave de host para este servidor ha cambiado desde su última conexión. Esto podría indicar un problema de seguridad.", - "previousKey": "Clave anterior", - "newFingerprint": "Nueva huella dactilar", - "fingerprint": "Huella dactilar", - "verifyInstructions": "Si confía en este host, haga clic en Aceptar para continuar y guardar esta huella dactilar para conexiones futuras.", - "securityWarning": "Advertencia de seguridad", - "acceptAndContinue": "Aceptar y continuar", - "acceptNewKey": "Aceptar nueva clave y continuar" - }, "errors": { "notFound": "Página no encontrada", "unauthorized": "Acceso no autorizado", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Anfitrión y barra lateral", "snippetsSettings": "Fragmentos", - "confirmSnippetExecution": "Confirmar ejecución de Snippet", - "confirmSnippetExecutionDesc": "Mostrar diálogo de confirmación antes de ejecutar fragmentos", "updateSettings": "Actualizaciones", "disableUpdateCheck": "Desactivar revisión de actualización", "disableUpdateCheckDesc": "Deja de buscar nuevas versiones en el arranque y el tablero. Reduce las solicitudes de red.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 o ejemplo.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/fi_FI.json b/src/locales/translated/fi_FI.json index addf2a18..6fce4c8c 100644 --- a/src/locales/translated/fi_FI.json +++ b/src/locales/translated/fi_FI.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Käyttäjätunnukset", "credentialsViewer": "Käyttöoikeustietojen Katselin", "manageYourSSHCredentials": "Hallitse SSH tunnuksiasi turvallisesti", "addCredential": "Lisää Käyttöoikeustieto", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Tämä lisää turvallisesti julkisen avaimen kohdetiedoston ~/.ssh/authorized_keys tiedostoon ilman olemassa olevien avainten korvaamista. Toiminto on palautettavissa.", "chooseHostToDeploy": "Valitse isäntä johon haluat ottaa käyttöön...", "deploying": "Otetaan Käyttöön...", - "copyDeployCommand": "Kopioi Deploy Komento", - "copyDeployCommandDescription": "Kopioi komento, jolla julkinen avain lisätään manuaalisesti isännän authorized_keys tiedostoon. Hyödyllinen, kun salasanan todennus on pois päältä.", - "deployCommandCopied": "Julkaise komento kopioitu leikepöydälle", - "manualDeployInfo": "Liitä tämä komento kohdeisännän päätteeseen (esim. Proxmox konsoli, IPMI tai fyysinen pääsy) lisätäksesi SSH avaimen.", "name": "Nimi", "noHostsAvailable": "Ei isäntiä saatavilla", "noHostsMatchSearch": "Yksikään isäntä ei vastaa hakuasi", @@ -303,9 +298,7 @@ "createFolder": "Luo Kansio", "editFolder": "Muokkaa Kansiota", "editFolderDescription": "Muokkaa snippet-kansiota", - "createFolderDescription": "Järjestä leikkeet kansioihin", - "confirmExecution": "Suorita \"{{name}}\"?", - "confirmExecutionDesc": "Oletko varma, että haluat suorittaa tämän ohjelman?" + "createFolderDescription": "Järjestä leikkeet kansioihin" }, "commandHistory": { "title": "Historia", @@ -409,7 +402,6 @@ "required": "Pakollinen", "optional": "Valinnainen", "connect": "Yhdistä", - "copied": "Kopioitu", "connecting": "Yhdistetään...", "creating": "Luodaan...", "clear": "Tyhjennä", @@ -502,7 +494,6 @@ "checking": "Tarkistetaan...", "checkingDatabase": "Tarkistetaan tietokantayhteyttä...", "checkingAuthentication": "Tarkistetaan todennusta...", - "backendReconnected": "Palvelinyhteys palautettu", "actions": "Toiminnot", "remove": "Poista", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Isäntälaitteen Hallinta", - "hosts": "Isäntä", "sshHosts": "Ssh Isäntä", "noHosts": "Ei Ssh Isäntiä", "noHostsMessage": "Et ole vielä lisännyt yhtään SSH isäntää. Napsauta \"Lisää isäntä\" aloittaaksesi.", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON-tiedoston tuonti epäonnistui", "connectionDetails": "Yhteyden Tiedot", "organization": "Organisaatio", - "ipAddress": "IP-osoite tai isäntänimi", + "ipAddress": "Ip Osoite", "port": "Portti", "name": "Nimi", "username": "Käyttäjätunnus", @@ -925,8 +915,6 @@ "key": "Avain", "credential": "Käyttöoikeustiedot", "none": "Ei Mitään", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Pyytää sinua OPKSSH web-authiin jokaisen yhteyden yhteydessä. Käyttöoikeustunnuksesi kestää 24 tuntia, kunnes se on uusittava.", "selectCredential": "Valitse Käyttöoikeustieto", "selectCredentialPlaceholder": "Valitse käyttäjätunnus...", "credentialRequired": "Käyttöoikeustiedot vaaditaan, kun käytetään käyttöoikeustietojen todennusta", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Näppäimistöinteraktiivinen todennus", "noneAuthDescription": "Tämä todennusmenetelmä käyttää näppäimistöinteraktiivista todennusta yhdistettäessä SSH-palvelimeen.", "noneAuthDetails": "Näppäimistötodennus antaa palvelimen pyytää sinulta tunnistetietoja yhteyden muodostamisen aikana. Tämä on hyödyllistä palvelimilla, jotka vaativat monivaiheisen todennuksen tai jos et halua tallentaa tunnistetietoja paikallisesti.", - "opksshAuthTitle": "OPKSSH Todennus", "forceKeyboardInteractive": "Pakota näppäimistöinteraktiivinen", "forceKeyboardInteractiveDesc": "Pakottaa näppäimistöinteraktiivisen todennuksen käytön. Tätä vaaditaan joskus palvelimilla, jotka käyttävät kaksivaiheista todennusta (TOTP/2FA).", "overrideCredentialUsername": "Ohita tunnistetiedot Käyttäjätunnus", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Avaa selaimessa", "warpgateContinue": "Olen Suorittanut Todentamisen", "warpgateTimeout": "Warpgate autentikoinnin aikakatkaisu. Yhdistä uudelleen.", - "opksshAuthRequired": "OPKSSH Todennus Vaaditaan", - "opksshAuthDescription": "Lopeta todennus selaimessasi jatkaaksesi. Tämä istunto pysyy voimassa 24 tuntia.", - "opksshAuthUrl": "Todennuksen URL", - "opksshOpenBrowser": "Avaa selain todennettavaksi", - "opksshWaitingForAuth": "Odotetaan todennusta selaimessa...", - "opksshAuthenticating": "Käsitellään todennusta...", - "opksshTimeout": "Todennus aikakatkaistiin. Yritä uudelleen.", - "opksshAuthFailed": "Todennus epäonnistui. Tarkista käyttäjätunnuksesi ja yritä uudelleen.", - "opksshConfigMissing": "OPKSSH konfiguraatiota ei löytynyt. Luo ~/.opk/config.yml OIDC palveluntarjoajan asetuksissa. Katso dokumentaatio: https://github.com/openpubkey/opkssh#konfiguraatio", "sudoPasswordPopupTitle": "Syötä salasana?", "sudoPasswordPopupHint": "Lisää painamalla Enter, hylkää painamalla Esc", "sudoPasswordPopupConfirm": "Lisää", @@ -1359,8 +1337,7 @@ "automaticFallback": "Yritetään automaattisesti {{method}} todennusta...", "totpTimeout": "TOTP vahvistuksen aikakatkaisu. Yhdistä uudelleen.", "passwordTimeout": "Salasanan varmennuksen aikakatkaisu. Yhdistä uudelleen.", - "connectionRejected": "Palvelin hylkäsi yhteyden. Tarkista todennus ja verkon asetukset.", - "hostKeyRejected": "SSH isäntäavaimen vahvistus hylätty. Yhteys peruutettu." + "connectionRejected": "Palvelin hylkäsi yhteyden. Tarkista todennus ja verkon asetukset." }, "fileManager": { "title": "Tiedostojen Hallinta", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Salasanan palautus onnistui", "passwordResetSuccessDesc": "Salasanasi on nollattu onnistuneesti. Voit nyt kirjautua sisään uudella salasanallasi." }, - "hostKey": { - "verifyNewHost": "Vahvista Ssh Isäntäavain", - "keyChangedWarning": "Ssh Isäntäavain Vaihdettu", - "firstConnectionTitle": "Yhteyden muodostaminen tähän palvelimeen ensimmäistä kertaa", - "firstConnectionDescription": "Tämän isännän aitoutta ei voida vahvistaa. Varmista sormenjälki vastaa sitä, mitä odotat.", - "keyChangedDescription": "Palvelimen isäntäavain on muuttunut viimeisen yhteytesi jälkeen. Tämä saattaa merkitä tietoturvaongelmaa.", - "previousKey": "Edellinen Avain", - "newFingerprint": "Uusi Sormenjälki", - "fingerprint": "Sormenjälki", - "verifyInstructions": "Jos luotat tähän isäntään, jatka ja tallenna sormenjälki tulevia yhteyksiä varten.", - "securityWarning": "Turvallisuusvaroitus", - "acceptAndContinue": "Hyväksy & Jatka", - "acceptNewKey": "Hyväksy Uusi Avain Ja Jatka" - }, "errors": { "notFound": "Sivua ei löytynyt", "unauthorized": "Luvaton pääsy", @@ -2063,8 +2026,6 @@ "terminalSettings": "Pääte", "hostSidebarSettings": "Isäntä ja sivupalkki", "snippetsSettings": "Projekti", - "confirmSnippetExecution": "Vahvista Projektin Suoritus", - "confirmSnippetExecutionDesc": "Näytä vahvistusvalintaikkuna ennen snippettien suorittamista", "updateSettings": "Päivitykset", "disableUpdateCheck": "Poista Päivitystarkistus Käytöstä", "disableUpdateCheckDesc": "Lopeta uusien versioiden tarkistaminen käynnistettäessä ja hallintapaneelissa. Vähennä verkkopyyntöjä.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 tai esimerkki.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/fr_FR.json b/src/locales/translated/fr_FR.json index 1cfaf419..4931459c 100644 --- a/src/locales/translated/fr_FR.json +++ b/src/locales/translated/fr_FR.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Identifiants", "credentialsViewer": "Visionneuse des identifiants", "manageYourSSHCredentials": "Gérez vos identifiants SSH en toute sécurité", "addCredential": "Ajouter un mot de passe", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Ceci ajoutera la clé publique au fichier ~/.ssh/authorized_keys de l'hôte cible sans écraser les clés existantes. L'opération est réversible.", "chooseHostToDeploy": "Choisissez un hôte à déployer vers...", "deploying": "Déploiement...", - "copyDeployCommand": "Copier la commande de déploiement", - "copyDeployCommandDescription": "Copiez une commande shell pour ajouter manuellement la clé publique au fichier authorized_keys d'un hôte. Utile lorsque l'authentification par mot de passe est désactivée.", - "deployCommandCopied": "Déployer la commande copiée dans le presse-papiers", - "manualDeployInfo": "Collez cette commande dans le terminal de l'hôte cible (par exemple, console Proxmox, IPMI, ou accès physique) pour ajouter la clé SSH.", "name": "Nom", "noHostsAvailable": "Aucun hôte disponible", "noHostsMatchSearch": "Aucun hôte ne correspond à votre recherche", @@ -303,9 +298,7 @@ "createFolder": "Créer un dossier", "editFolder": "Modifier le dossier", "editFolderDescription": "Personnaliser votre dossier de snippet", - "createFolderDescription": "Organiser vos modules de texte dans des dossiers", - "confirmExecution": "Exécuter \"{{name}} \" ?", - "confirmExecutionDesc": "Êtes-vous sûr de vouloir exécuter ce snippet?" + "createFolderDescription": "Organiser vos modules de texte dans des dossiers" }, "commandHistory": { "title": "Historique", @@ -409,7 +402,6 @@ "required": "Requis", "optional": "Optionnel", "connect": "Connecter", - "copied": "Copié", "connecting": "Connexion en cours...", "creating": "Création en cours...", "clear": "Nettoyer", @@ -502,7 +494,6 @@ "checking": "Vérification...", "checkingDatabase": "Vérification de la connexion à la base de données...", "checkingAuthentication": "Vérification de l'authentification...", - "backendReconnected": "Connexion au serveur restaurée", "actions": "Actions", "remove": "Retirer", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Responsable d'hôte", - "hosts": "Hôtes", "sshHosts": "Hôtes SSH", "noHosts": "Aucun hôte SSH", "noHostsMessage": "Vous n'avez pas encore ajouté d'hôtes SSH. Cliquez sur \"Ajouter Hôte\" pour commencer.", @@ -846,7 +836,7 @@ "failedToImportJson": "Impossible d'importer le fichier JSON", "connectionDetails": "Détails de la connexion", "organization": "Organisation", - "ipAddress": "Adresse IP ou nom d'hôte", + "ipAddress": "Adresse IP", "port": "Port", "name": "Nom", "username": "Nom d'utilisateur", @@ -925,8 +915,6 @@ "key": "Clés", "credential": "Identification", "none": "Aucun", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Vous invite à l'authentification OPKSSH à chaque connexion. Votre jeton d'authentification durera 24 heures jusqu'à ce qu'il soit renouvelé.", "selectCredential": "Sélectionner les informations d'identification", "selectCredentialPlaceholder": "Choisissez un identifiant...", "credentialRequired": "Les informations d'identification sont requises lors de l'utilisation de l'authentification", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Authentification interactive du clavier", "noneAuthDescription": "Cette méthode d'authentification utilisera l'authentification interactive au clavier lors de la connexion au serveur SSH.", "noneAuthDetails": "L'authentification interactive au clavier permet au serveur de vous demander des informations d'identification pendant la connexion. Ceci est utile pour les serveurs qui nécessitent une authentification multi-facteurs ou si vous ne voulez pas enregistrer les identifiants localement.", - "opksshAuthTitle": "Authentification OPKSSH", "forceKeyboardInteractive": "Forcer l'interaction du clavier", "forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification interactive au clavier. Ceci est parfois nécessaire pour les serveurs qui utilisent l'authentification à deux facteurs (TOTP/2FA).", "overrideCredentialUsername": "Remplacer le nom d'utilisateur de l'identifiant", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Ouvrir dans le navigateur", "warpgateContinue": "J'ai terminé l'authentification", "warpgateTimeout": "Délai d'authentification de Warpgate. Veuillez vous reconnecter.", - "opksshAuthRequired": "Authentification OPKSSH requise", - "opksshAuthDescription": "Authentification complète dans votre navigateur pour continuer. Cette session restera valide pendant 24 heures.", - "opksshAuthUrl": "URL d'authentification", - "opksshOpenBrowser": "Ouvrez le navigateur pour vous authentifier", - "opksshWaitingForAuth": "En attente d'authentification dans le navigateur...", - "opksshAuthenticating": "Traitement de l'authentification...", - "opksshTimeout": "Délai d'authentification dépassé. Veuillez réessayer.", - "opksshAuthFailed": "L'authentification a échoué. Veuillez vérifier vos identifiants et réessayer.", - "opksshConfigMissing": "La configuration OPKSSH est introuvable. Veuillez créer ~/.opk/config.yml avec vos paramètres de fournisseur OIDC. Voir la documentation : https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Insérer un mot de passe ?", "sudoPasswordPopupHint": "Appuyez sur Entrée pour insérer, Échap pour rejeter", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Essai automatique de l'authentification {{method}}...", "totpTimeout": "Délai de vérification TOTP. Veuillez vous reconnecter.", "passwordTimeout": "Délai de vérification du mot de passe. Veuillez vous reconnecter.", - "connectionRejected": "Connexion refusée par le serveur. Veuillez vérifier votre authentification et la configuration du réseau.", - "hostKeyRejected": "La vérification de la clé d'hôte SSH a été rejetée. La connexion a été annulée." + "connectionRejected": "Connexion refusée par le serveur. Veuillez vérifier votre authentification et la configuration du réseau." }, "fileManager": { "title": "Gestionnaire de fichiers", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Mot de passe réinitialisé avec succès", "passwordResetSuccessDesc": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe." }, - "hostKey": { - "verifyNewHost": "Vérifier la clé d'hôte SSH", - "keyChangedWarning": "Clé d'hôte SSH modifiée", - "firstConnectionTitle": "Première connexion à cet hôte", - "firstConnectionDescription": "L'authenticité de cet hôte ne peut pas être établie. Vérifiez que l'empreinte digitale correspond à ce que vous attendez.", - "keyChangedDescription": "La clé hôte de ce serveur a changé depuis votre dernière connexion. Cela pourrait indiquer un problème de sécurité.", - "previousKey": "Clé précédente", - "newFingerprint": "Nouvelle empreinte digitale", - "fingerprint": "Empreinte digitale", - "verifyInstructions": "Si vous faites confiance à cet hôte, cliquez sur Accepter pour continuer et enregistrer cette empreinte digitale pour de futures connexions.", - "securityWarning": "Avertissement de sécurité", - "acceptAndContinue": "Accepter et continuer", - "acceptNewKey": "Accepter la nouvelle clé et continuer" - }, "errors": { "notFound": "Page introuvable", "unauthorized": "Accès non autorisé", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Hôte et barre latérale", "snippetsSettings": "Extraits", - "confirmSnippetExecution": "Confirmer l'exécution du snippet", - "confirmSnippetExecutionDesc": "Afficher la boîte de dialogue de confirmation avant d'exécuter les snippets", "updateSettings": "Mises à jour", "disableUpdateCheck": "Désactiver la vérification des mises à jour", "disableUpdateCheckDesc": "Arrêter la vérification des nouvelles versions au démarrage et au tableau de bord. Réduit les requêtes réseau.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 ou exemple.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/he_IL.json b/src/locales/translated/he_IL.json index 89f19f13..cb35a482 100644 --- a/src/locales/translated/he_IL.json +++ b/src/locales/translated/he_IL.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "אישורים", "credentialsViewer": "מציג אישורים", "manageYourSSHCredentials": "נהל את פרטי ה-SSH שלך בצורה מאובטחת", "addCredential": "הוסף אישור", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "פעולה זו תוסיף בבטחה את המפתח הציבורי לקובץ ~/.ssh/authorized_keys של מארח היעד מבלי להחליף מפתחות קיימים. הפעולה היא הפיכה.", "chooseHostToDeploy": "בחר מארח לפריסה...", "deploying": "פריסה...", - "copyDeployCommand": "העתק את פקודת הפריסה", - "copyDeployCommandDescription": "העתק פקודת מעטפת כדי להוסיף ידנית את המפתח הציבורי לקובץ authorized_keys של מחשב מארח. שימושי כאשר אימות סיסמה מושבת.", - "deployCommandCopied": "פקודת הפריסה הועתקה ללוח", - "manualDeployInfo": "הדבק פקודה זו בטרמינל של מחשב המארח (למשל, קונסולת Proxmox, IPMI או גישה פיזית) כדי להוסיף את מפתח ה-SSH.", "name": "שֵׁם", "noHostsAvailable": "אין מארחים זמינים", "noHostsMatchSearch": "אין מארחים התואמים את החיפוש שלך", @@ -303,9 +298,7 @@ "createFolder": "צור תיקייה", "editFolder": "עריכת תיקייה", "editFolderDescription": "התאם אישית את תיקיית הקטעים שלך", - "createFolderDescription": "ארגנו את הקטעים שלכם בתיקיות", - "confirmExecution": "להפעיל את \"{{name}}\"?", - "confirmExecutionDesc": "האם אתה בטוח שאתה רוצה להפעיל את הקטע הזה?" + "createFolderDescription": "ארגנו את הקטעים שלכם בתיקיות" }, "commandHistory": { "title": "הִיסטוֹרִיָה", @@ -409,7 +402,6 @@ "required": "דָרוּשׁ", "optional": "אופציונלי", "connect": "לְחַבֵּר", - "copied": "מוּעֲתָק", "connecting": "מְקַשֵׁר...", "creating": "יוצר...", "clear": "בָּרוּר", @@ -502,7 +494,6 @@ "checking": "בודק...", "checkingDatabase": "בודק חיבור למסד הנתונים...", "checkingAuthentication": "בודק אימות...", - "backendReconnected": "חיבור השרת שוחזר", "actions": "פעולות", "remove": "לְהַסִיר", "revoke": "לְבַטֵל", @@ -815,7 +806,6 @@ }, "hosts": { "title": "מנהל מארח", - "hosts": "מארחים", "sshHosts": "מארחי SSH", "noHosts": "אין מארחי SSH", "noHostsMessage": "עדיין לא הוספת מארחי SSH. לחץ על \"הוסף מארח\" כדי להתחיל.", @@ -846,7 +836,7 @@ "failedToImportJson": "ייבוא קובץ JSON נכשל", "connectionDetails": "פרטי חיבור", "organization": "אִרגוּן", - "ipAddress": "כתובת IP או שם מארח", + "ipAddress": "כתובת IP", "port": "נָמָל", "name": "שֵׁם", "username": "שם משתמש", @@ -925,8 +915,6 @@ "key": "מַפְתֵחַ", "credential": "תְעוּדָה", "none": "אַף לֹא אֶחָד", - "opkssh": "אופקש", - "opksshAuthDescription": "מבקש ממך אימות אינטרנט של OPKSSH בכל חיבור. אסימון האימות שלך יהיה בתוקף למשך 24 שעות עד שיהיה צורך לחדשו.", "selectCredential": "בחר אישור", "selectCredentialPlaceholder": "בחר אישור...", "credentialRequired": "נדרשת אישור בעת שימוש באימות אישורים", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "אימות אינטראקטיבי באמצעות מקלדת", "noneAuthDescription": "שיטת אימות זו תשתמש באימות אינטראקטיבי באמצעות מקלדת בעת התחברות לשרת SSH.", "noneAuthDetails": "אימות אינטראקטיבי באמצעות מקלדת מאפשר לשרת לבקש ממך אישורים במהלך החיבור. זה שימושי עבור שרתים הדורשים אימות רב-גורמי או אם אינך מעוניין לשמור אישורים באופן מקומי.", - "opksshAuthTitle": "אימות OPKSSH", "forceKeyboardInteractive": "כפיית מקלדת אינטראקטיבית", "forceKeyboardInteractiveDesc": "כופה שימוש באימות אינטראקטיבי באמצעות מקלדת. זה נדרש לעיתים עבור שרתים המשתמשים באימות דו-שלבי (TOTP/2FA).", "overrideCredentialUsername": "עקיפת שם משתמש של פרטי כניסה", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "פתח בדפדפן", "warpgateContinue": "סיימתי את האימות", "warpgateTimeout": "פסק זמן לאימות Warpgate. אנא התחבר מחדש.", - "opksshAuthRequired": "נדרש אימות OPKSSH", - "opksshAuthDescription": "השלם את האימות בדפדפן שלך כדי להמשיך. סשן זה יישאר תקף למשך 24 שעות.", - "opksshAuthUrl": "כתובת URL לאימות", - "opksshOpenBrowser": "פתח את הדפדפן כדי לאמת", - "opksshWaitingForAuth": "ממתין לאימות בדפדפן...", - "opksshAuthenticating": "מעבד אימות...", - "opksshTimeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב.", - "opksshAuthFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.", - "opksshConfigMissing": "תצורת OPKSSH לא נמצאה. אנא צור ~/.opk/config.yml עם הגדרות ספק ה-OIDC שלך. עיין בתיעוד: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "להכניס סיסמה?", "sudoPasswordPopupHint": "לחץ על Enter כדי להוסיף, Esc כדי לסגור", "sudoPasswordPopupConfirm": "לְהַכנִיס", @@ -1359,8 +1337,7 @@ "automaticFallback": "מנסה אוטומטית אימות {{method}} ...", "totpTimeout": "פסק זמן לאימות TOTP. אנא התחבר מחדש.", "passwordTimeout": "פסק זמן לאימות סיסמה. אנא התחבר מחדש.", - "connectionRejected": "החיבור נדחה על ידי השרת. אנא בדוק את האימות ותצורת הרשת שלך.", - "hostKeyRejected": "אימות מפתח מארח SSH נדחה. החיבור בוטל." + "connectionRejected": "החיבור נדחה על ידי השרת. אנא בדוק את האימות ותצורת הרשת שלך." }, "fileManager": { "title": "מנהל הקבצים", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "איפוס הסיסמה הצליח", "passwordResetSuccessDesc": "הסיסמה שלך אופסה בהצלחה. כעת תוכל להתחבר באמצעות הסיסמה החדשה שלך." }, - "hostKey": { - "verifyNewHost": "אימות מפתח מארח SSH", - "keyChangedWarning": "מפתח מארח SSH שונה", - "firstConnectionTitle": "בפעם הראשונה שמתחברת למארח הזה", - "firstConnectionDescription": "לא ניתן לקבוע את האותנטיות של מארח זה. ודא שטביעת האצבע תואמת את מה שאתה מצפה.", - "keyChangedDescription": "מפתח המארח של שרת זה השתנה מאז החיבור האחרון שלך. זה יכול להצביע על בעיית אבטחה.", - "previousKey": "מפתח קודם", - "newFingerprint": "טביעת אצבע חדשה", - "fingerprint": "טְבִיעַת אֶצבָּעוֹת", - "verifyInstructions": "אם אתה בוטח במארח זה, לחץ על קבל כדי להמשיך ולשמור את טביעת האצבע הזו לחיבורים עתידיים.", - "securityWarning": "אזהרת אבטחה", - "acceptAndContinue": "קבל והמשך", - "acceptNewKey": "קבל את המפתח החדש והמשך" - }, "errors": { "notFound": "הדף לא נמצא", "unauthorized": "גישה לא מורשית", @@ -2063,8 +2026,6 @@ "terminalSettings": "מָסוֹף", "hostSidebarSettings": "מארח וסרגל צד", "snippetsSettings": "קטעי טקסט", - "confirmSnippetExecution": "אישור ביצוע קטע", - "confirmSnippetExecutionDesc": "הצג תיבת דו-שיח לאישור לפני ביצוע קטעי טקסט", "updateSettings": "עדכונים", "disableUpdateCheck": "השבתת בדיקת עדכונים", "disableUpdateCheckDesc": "הפסק לבדוק גרסאות חדשות בעת ההפעלה ובלוח המחוונים. מפחית בקשות רשת.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 או example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/hi_IN.json b/src/locales/translated/hi_IN.json index 0db18306..95d27d8e 100644 --- a/src/locales/translated/hi_IN.json +++ b/src/locales/translated/hi_IN.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "साख", "credentialsViewer": "क्रेडेंशियल व्यूअर", "manageYourSSHCredentials": "अपने SSH क्रेडेंशियल्स को सुरक्षित रूप से प्रबंधित करें", "addCredential": "क्रेडेंशियल जोड़ें", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "इससे सार्वजनिक कुंजी सुरक्षित रूप से लक्ष्य होस्ट की ~/.ssh/authorized_keys फ़ाइल में जुड़ जाएगी, और मौजूदा कुंजियों को ओवरराइट नहीं किया जाएगा। यह प्रक्रिया प्रतिवर्ती है।", "chooseHostToDeploy": "परिनियोजन के लिए होस्ट चुनें...", "deploying": "तैनाती जारी है...", - "copyDeployCommand": "परिनियोजन कमांड कॉपी करें", - "copyDeployCommandDescription": "किसी होस्ट की authorized_keys फ़ाइल में सार्वजनिक कुंजी को मैन्युअल रूप से जोड़ने के लिए शेल कमांड को कॉपी करें। यह तब उपयोगी होता है जब पासवर्ड प्रमाणीकरण अक्षम हो।", - "deployCommandCopied": "डिप्लॉयमेंट कमांड क्लिपबोर्ड पर कॉपी हो गई है", - "manualDeployInfo": "SSH कुंजी जोड़ने के लिए इस कमांड को टारगेट होस्ट के टर्मिनल (जैसे, प्रॉक्समॉक्स कंसोल, IPMI, या फिजिकल एक्सेस) में पेस्ट करें।", "name": "नाम", "noHostsAvailable": "कोई होस्ट उपलब्ध नहीं है", "noHostsMatchSearch": "आपकी खोज से मेल खाने वाले कोई होस्ट नहीं हैं", @@ -303,9 +298,7 @@ "createFolder": "फ़ोल्डर बनाएँ", "editFolder": "फ़ोल्डर संपादित करें", "editFolderDescription": "अपने स्निपेट फ़ोल्डर को अनुकूलित करें", - "createFolderDescription": "अपने लेखों के अंशों को फ़ोल्डरों में व्यवस्थित करें।", - "confirmExecution": "\"{{name}}\" निष्पादित करें?", - "confirmExecutionDesc": "क्या आप वाकई इस कोड स्निपेट को चलाना चाहते हैं?" + "createFolderDescription": "अपने लेखों के अंशों को फ़ोल्डरों में व्यवस्थित करें।" }, "commandHistory": { "title": "इतिहास", @@ -409,7 +402,6 @@ "required": "आवश्यक", "optional": "वैकल्पिक", "connect": "जोड़ना", - "copied": "कॉपी किया गया", "connecting": "कनेक्ट हो रहा है...", "creating": "सृजन...", "clear": "स्पष्ट", @@ -502,7 +494,6 @@ "checking": "जाँच चल रही है...", "checkingDatabase": "डेटाबेस कनेक्शन की जाँच की जा रही है...", "checkingAuthentication": "प्रमाणीकरण की जाँच की जा रही है...", - "backendReconnected": "सर्वर कनेक्शन बहाल हो गया", "actions": "कार्रवाई", "remove": "निकालना", "revoke": "रद्द करना", @@ -815,7 +806,6 @@ }, "hosts": { "title": "मेजबान प्रबंधक", - "hosts": "मेजबान", "sshHosts": "एसएसएच होस्ट", "noHosts": "कोई एसएसएच होस्ट नहीं", "noHostsMessage": "आपने अभी तक कोई SSH होस्ट नहीं जोड़ा है। शुरू करने के लिए \"होस्ट जोड़ें\" पर क्लिक करें।", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON फ़ाइल आयात करने में विफल", "connectionDetails": "कनेक्शन विवरण", "organization": "संगठन", - "ipAddress": "आईपी पता या होस्टनाम", + "ipAddress": "आईपी पता", "port": "पत्तन", "name": "नाम", "username": "उपयोगकर्ता नाम", @@ -925,8 +915,6 @@ "key": "चाबी", "credential": "क्रेडेंशियल", "none": "कोई नहीं", - "opkssh": "ओपीकेएसएच", - "opksshAuthDescription": "हर बार कनेक्ट होने पर यह आपसे OPKSSH वेब-प्रमाणीकरण के लिए पूछेगा। आपका प्रमाणीकरण टोकन 24 घंटे तक वैध रहेगा, जिसके बाद इसे नवीनीकृत करना होगा।", "selectCredential": "क्रेडेंशियल चुनें", "selectCredentialPlaceholder": "एक प्रमाण पत्र चुनें...", "credentialRequired": "क्रेडेंशियल प्रमाणीकरण का उपयोग करते समय क्रेडेंशियल आवश्यक है।", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "कीबोर्ड-इंटरैक्टिव प्रमाणीकरण", "noneAuthDescription": "एसएसएच सर्वर से कनेक्ट करते समय यह प्रमाणीकरण विधि कीबोर्ड-इंटरैक्टिव प्रमाणीकरण का उपयोग करेगी।", "noneAuthDetails": "कीबोर्ड-इंटरैक्टिव प्रमाणीकरण सर्वर को कनेक्शन के दौरान आपसे क्रेडेंशियल मांगने की अनुमति देता है। यह उन सर्वरों के लिए उपयोगी है जिन्हें मल्टी-फैक्टर प्रमाणीकरण की आवश्यकता होती है या यदि आप क्रेडेंशियल को स्थानीय रूप से सहेजना नहीं चाहते हैं।", - "opksshAuthTitle": "ओपीकेएसएसएच प्रमाणीकरण", "forceKeyboardInteractive": "फ़ोर्स कीबोर्ड-इंटरैक्टिव", "forceKeyboardInteractiveDesc": "कीबोर्ड-आधारित प्रमाणीकरण को अनिवार्य बनाता है। यह कभी-कभी उन सर्वरों के लिए आवश्यक होता है जो दो-कारक प्रमाणीकरण (TOTP/2FA) का उपयोग करते हैं।", "overrideCredentialUsername": "क्रेडेंशियल उपयोगकर्ता नाम को ओवरराइड करें", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "ब्राउज़र में खोलें", "warpgateContinue": "मैंने प्रमाणीकरण पूरा कर लिया है", "warpgateTimeout": "वॉरपगेट प्रमाणीकरण में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।", - "opksshAuthRequired": "OPKSSH प्रमाणीकरण आवश्यक है", - "opksshAuthDescription": "आगे बढ़ने के लिए अपने ब्राउज़र में प्रमाणीकरण पूरा करें। यह सत्र 24 घंटे तक वैध रहेगा।", - "opksshAuthUrl": "प्रमाणीकरण यूआरएल", - "opksshOpenBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें", - "opksshWaitingForAuth": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...", - "opksshAuthenticating": "प्रमाणीकरण प्रक्रिया जारी है...", - "opksshTimeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।", - "opksshAuthFailed": "प्रमाणीकरण विफल रहा। कृपया अपनी पहचान सत्यापित करें और पुनः प्रयास करें।", - "opksshConfigMissing": "OPKSSH कॉन्फ़िगरेशन नहीं मिला। कृपया अपने OIDC प्रदाता सेटिंग्स के साथ ~/.opk/config.yml फ़ाइल बनाएँ। दस्तावेज़ देखें: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "पासवर्ड डालें?", "sudoPasswordPopupHint": "सम्मिलित करने के लिए Enter दबाएँ, हटाने के लिए Esc दबाएँ", "sudoPasswordPopupConfirm": "डालना", @@ -1359,8 +1337,7 @@ "automaticFallback": "Automatically trying {{method}} authentication...", "totpTimeout": "टीओटीपी सत्यापन में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।", "passwordTimeout": "पासवर्ड सत्यापन में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।", - "connectionRejected": "सर्वर द्वारा कनेक्शन अस्वीकृत कर दिया गया है। कृपया अपनी प्रमाणीकरण और नेटवर्क कॉन्फ़िगरेशन की जाँच करें।", - "hostKeyRejected": "SSH होस्ट कुंजी सत्यापन अस्वीकृत। कनेक्शन रद्द।" + "connectionRejected": "सर्वर द्वारा कनेक्शन अस्वीकृत कर दिया गया है। कृपया अपनी प्रमाणीकरण और नेटवर्क कॉन्फ़िगरेशन की जाँच करें।" }, "fileManager": { "title": "फ़ाइल मैनेजर", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "पासवर्ड रीसेट सफल रहा", "passwordResetSuccessDesc": "आपका पासवर्ड सफलतापूर्वक रीसेट हो गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।" }, - "hostKey": { - "verifyNewHost": "SSH होस्ट कुंजी सत्यापित करें", - "keyChangedWarning": "SSH होस्ट कुंजी बदल गई", - "firstConnectionTitle": "पहली बार इस होस्ट से कनेक्ट हो रहा है", - "firstConnectionDescription": "इस होस्ट की प्रामाणिकता स्थापित नहीं की जा सकती। कृपया सुनिश्चित करें कि फिंगरप्रिंट आपकी अपेक्षा के अनुरूप है।", - "keyChangedDescription": "आपके पिछले कनेक्शन के बाद से इस सर्वर की होस्ट कुंजी बदल गई है। यह किसी सुरक्षा समस्या का संकेत हो सकता है।", - "previousKey": "पिछली कुंजी", - "newFingerprint": "नया फिंगरप्रिंट", - "fingerprint": "अंगुली की छाप", - "verifyInstructions": "यदि आप इस होस्ट पर भरोसा करते हैं, तो जारी रखने के लिए स्वीकार करें पर क्लिक करें और भविष्य के कनेक्शनों के लिए इस फिंगरप्रिंट को सहेजें।", - "securityWarning": "सुरक्षा चेतावनी", - "acceptAndContinue": "स्वीकार करें और जारी रखें", - "acceptNewKey": "नई कुंजी स्वीकार करें और जारी रखें" - }, "errors": { "notFound": "पृष्ठ नहीं मिला", "unauthorized": "अनधिकृत पहुंच", @@ -2063,8 +2026,6 @@ "terminalSettings": "टर्मिनल", "hostSidebarSettings": "होस्ट और साइडबार", "snippetsSettings": "स्निपेट्स", - "confirmSnippetExecution": "स्निपेट निष्पादन की पुष्टि करें", - "confirmSnippetExecutionDesc": "स्निपेट्स को निष्पादित करने से पहले पुष्टिकरण डायलॉग दिखाएँ", "updateSettings": "अपडेट", "disableUpdateCheck": "अपडेट जांच अक्षम करें", "disableUpdateCheckDesc": "स्टार्टअप और डैशबोर्ड पर नए संस्करणों की जाँच करना बंद करें। इससे नेटवर्क अनुरोध कम होते हैं।", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 या example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/hu_HU.json b/src/locales/translated/hu_HU.json index e30b91f9..a4d9dca9 100644 --- a/src/locales/translated/hu_HU.json +++ b/src/locales/translated/hu_HU.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Hitelesítő adatok", "credentialsViewer": "Hitelesítő adatok megtekintője", "manageYourSSHCredentials": "SSH-hitelesítő adatainak biztonságos kezelése", "addCredential": "Hitelesítő adat hozzáadása", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Ez biztonságosan hozzáadja a nyilvános kulcsot a célgép ~/.ssh/authorized_keys fájljához a meglévő kulcsok felülírása nélkül. A művelet visszafordítható.", "chooseHostToDeploy": "Válasszon egy hosztot a telepítéshez...", "deploying": "Telepítés...", - "copyDeployCommand": "Telepítési parancs másolása", - "copyDeployCommandDescription": "Egy shell parancs másolásával manuálisan hozzáadható a nyilvános kulcs a gazdagép authorized_keys fájljához. Hasznos, ha a jelszó-hitelesítés le van tiltva.", - "deployCommandCopied": "A telepítési parancs a vágólapra másolva", - "manualDeployInfo": "Illeszd be ezt a parancsot a célgép termináljába (pl. Proxmox konzol, IPMI vagy fizikai hozzáférés) az SSH kulcs hozzáadásához.", "name": "Név", "noHostsAvailable": "Nincsenek elérhető házigazdák", "noHostsMatchSearch": "Nincsenek a keresésnek megfelelő házigazdák", @@ -303,9 +298,7 @@ "createFolder": "Mappa létrehozása", "editFolder": "Mappa szerkesztése", "editFolderDescription": "Kódrészlet mappa testreszabása", - "createFolderDescription": "Rendezd a kódrészleteket mappákba", - "confirmExecution": "Végrehajtani a következőt: \"{{name}}\"?", - "confirmExecutionDesc": "Biztosan végrehajtod ezt a kódrészletet?" + "createFolderDescription": "Rendezd a kódrészleteket mappákba" }, "commandHistory": { "title": "Történelem", @@ -409,7 +402,6 @@ "required": "Kívánt", "optional": "Választható", "connect": "Csatlakozás", - "copied": "Másolva", "connecting": "Kapcsolódás...", "creating": "Létrehozás...", "clear": "Világos", @@ -502,7 +494,6 @@ "checking": "Ellenőrzés...", "checkingDatabase": "Adatbázis-kapcsolat ellenőrzése...", "checkingAuthentication": "Hitelesítés ellenőrzése...", - "backendReconnected": "Szerverkapcsolat helyreállt", "actions": "Műveletek", "remove": "Eltávolítás", "revoke": "Visszavonás", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Házigazda-kezelő", - "hosts": "Házigazdák", "sshHosts": "SSH-hosztok", "noHosts": "Nincsenek SSH-hosztok", "noHostsMessage": "Még nem adott hozzá SSH-hosztokat. Kattintson a „Hoszt hozzáadása” gombra a kezdéshez.", @@ -846,7 +836,7 @@ "failedToImportJson": "Nem sikerült importálni a JSON fájlt", "connectionDetails": "Kapcsolat részletei", "organization": "Szervezet", - "ipAddress": "IP-cím vagy hostnév", + "ipAddress": "IP-cím", "port": "Kikötő", "name": "Név", "username": "Felhasználónév", @@ -925,8 +915,6 @@ "key": "Kulcsfontosságú", "credential": "Hitelesítő adat", "none": "Egyik sem", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Minden csatlakozáskor OPKSSH webes hitelesítést kér. A hitelesítési token 24 óráig érvényes, amíg meg nem újítják.", "selectCredential": "Hitelesítő adat kiválasztása", "selectCredentialPlaceholder": "Válasszon hitelesítő adatlapot...", "credentialRequired": "Hitelesítő adat szükséges hitelesítő adat alapú hitelesítéshez", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Billentyűzet-interaktív hitelesítés", "noneAuthDescription": "Ez a hitelesítési módszer billentyűzet-interaktív hitelesítést használ az SSH-kiszolgálóhoz való csatlakozáskor.", "noneAuthDetails": "A billentyűzet-interaktív hitelesítés lehetővé teszi, hogy a szerver kérje a hitelesítő adatokat a csatlakozás során. Ez olyan szervereknél hasznos, amelyek többtényezős hitelesítést igényelnek, vagy ha nem szeretné helyben menteni a hitelesítő adatokat.", - "opksshAuthTitle": "OPKSSH hitelesítés", "forceKeyboardInteractive": "Kényszerítse a billentyűzet interaktivitását", "forceKeyboardInteractiveDesc": "Kényszeríti a billentyűzet-interaktív hitelesítés használatát. Ez néha szükséges a kétfaktoros hitelesítést (TOTP/2FA) használó szervereknél.", "overrideCredentialUsername": "Hitelesítő adat felülírása Felhasználónév", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Megnyitás böngészőben", "warpgateContinue": "Befejeztem a hitelesítést", "warpgateTimeout": "Warpgate hitelesítési időtúllépés. Kérjük, csatlakozzon újra.", - "opksshAuthRequired": "OPKSSH hitelesítés szükséges", - "opksshAuthDescription": "A folytatáshoz végezze el a hitelesítést a böngészőjében. Ez a munkamenet 24 órán át érvényes marad.", - "opksshAuthUrl": "Hitelesítési URL", - "opksshOpenBrowser": "Böngésző megnyitása a hitelesítéshez", - "opksshWaitingForAuth": "Várakozás a böngésző hitelesítésére...", - "opksshAuthenticating": "Hitelesítés feldolgozása...", - "opksshTimeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra.", - "opksshAuthFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.", - "opksshConfigMissing": "Az OPKSSH konfiguráció nem található. Kérjük, hozza létre a ~/.opk/config.yml fájlt az OIDC-szolgáltató beállításaival. Lásd a dokumentációt: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Jelszó beszúrása?", "sudoPasswordPopupHint": "Nyomja meg az Enter billentyűt a beszúráshoz, az Esc billentyűt az elvetéshez", "sudoPasswordPopupConfirm": "Beszúrás", @@ -1359,8 +1337,7 @@ "automaticFallback": "Automatikusan megpróbáljuk a {{method}} hitelesítést...", "totpTimeout": "TOTP ellenőrzési időtúllépés. Kérjük, csatlakozzon újra.", "passwordTimeout": "Jelszó-ellenőrzési időtúllépés. Kérjük, csatlakozzon újra.", - "connectionRejected": "A szerver elutasította a kapcsolatot. Kérjük, ellenőrizze a hitelesítést és a hálózati konfigurációt.", - "hostKeyRejected": "SSH host kulcs ellenőrzése elutasítva. Kapcsolat megszakítva." + "connectionRejected": "A szerver elutasította a kapcsolatot. Kérjük, ellenőrizze a hitelesítést és a hálózati konfigurációt." }, "fileManager": { "title": "Fájlkezelő", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Jelszó visszaállítása sikeres", "passwordResetSuccessDesc": "Jelszava sikeresen visszaállításra került. Most már bejelentkezhet új jelszavával." }, - "hostKey": { - "verifyNewHost": "SSH host kulcs ellenőrzése", - "keyChangedWarning": "SSH állomáskulcs megváltozott", - "firstConnectionTitle": "Első csatlakozás ehhez a gazdagéphez", - "firstConnectionDescription": "A gazdagép hitelessége nem állapítható meg. Ellenőrizze, hogy az ujjlenyomat megfelel-e a vártnak.", - "keyChangedDescription": "A szerver host key-je megváltozott az utolsó csatlakozás óta. Ez biztonsági problémára utalhat.", - "previousKey": "Előző kulcs", - "newFingerprint": "Új ujjlenyomat", - "fingerprint": "Ujjlenyomat", - "verifyInstructions": "Ha megbízik ebben a gazdagépben, kattintson az Elfogadás gombra a folytatáshoz és az ujjlenyomat mentéséhez a jövőbeli kapcsolatokhoz.", - "securityWarning": "Biztonsági figyelmeztetés", - "acceptAndContinue": "Elfogadom és folytatom", - "acceptNewKey": "Új kulcs elfogadása és folytatás" - }, "errors": { "notFound": "Az oldal nem található", "unauthorized": "Jogosulatlan hozzáférés", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminál", "hostSidebarSettings": "Gazdagép és oldalsáv", "snippetsSettings": "Kódrészletek", - "confirmSnippetExecution": "Kódrészlet végrehajtásának megerősítése", - "confirmSnippetExecutionDesc": "Megerősítő párbeszédpanel megjelenítése a kódrészletek végrehajtása előtt", "updateSettings": "Frissítések", "disableUpdateCheck": "Frissítésellenőrzés letiltása", "disableUpdateCheckDesc": "Új verziók keresésének leállítása indításkor és az irányítópulton. Csökkenti a hálózati kérések számát.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 vagy example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/id_ID.json b/src/locales/translated/id_ID.json index a0f1393d..da037764 100644 --- a/src/locales/translated/id_ID.json +++ b/src/locales/translated/id_ID.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Kredensial", "credentialsViewer": "Penampil Kredensial", "manageYourSSHCredentials": "Kelola kredensial SSH Anda dengan aman.", "addCredential": "Tambahkan Kredensial", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Ini akan menambahkan kunci publik dengan aman ke file ~/.ssh/authorized_keys pada host target tanpa menimpa kunci yang sudah ada. Operasi ini dapat dibatalkan.", "chooseHostToDeploy": "Pilih host tempat untuk melakukan deployment...", "deploying": "Sedang melakukan penyebaran...", - "copyDeployCommand": "Salin Perintah Penyebaran", - "copyDeployCommandDescription": "Salin perintah shell untuk menambahkan kunci publik secara manual ke file authorized_keys host. Berguna saat otentikasi kata sandi dinonaktifkan.", - "deployCommandCopied": "Perintah penyebaran disalin ke papan klip", - "manualDeployInfo": "Salin perintah ini ke terminal host target (misalnya, konsol Proxmox, IPMI, atau akses fisik) untuk menambahkan kunci SSH.", "name": "Nama", "noHostsAvailable": "Tidak ada tuan rumah yang tersedia.", "noHostsMatchSearch": "Tidak ada host yang sesuai dengan pencarian Anda.", @@ -303,9 +298,7 @@ "createFolder": "Buat Folder", "editFolder": "Edit Folder", "editFolderDescription": "Sesuaikan folder cuplikan Anda", - "createFolderDescription": "Susun cuplikan Anda ke dalam folder.", - "confirmExecution": "Jalankan \"{{name}}\"?", - "confirmExecutionDesc": "Apakah Anda yakin ingin menjalankan cuplikan kode ini?" + "createFolderDescription": "Susun cuplikan Anda ke dalam folder." }, "commandHistory": { "title": "Sejarah", @@ -409,7 +402,6 @@ "required": "Diperlukan", "optional": "Opsional", "connect": "Menghubungkan", - "copied": "Disalin", "connecting": "Menghubungkan...", "creating": "Membuat...", "clear": "Jernih", @@ -502,7 +494,6 @@ "checking": "Sedang memeriksa...", "checkingDatabase": "Memeriksa koneksi basis data...", "checkingAuthentication": "Memeriksa autentikasi...", - "backendReconnected": "Koneksi server dipulihkan.", "actions": "Tindakan", "remove": "Menghapus", "revoke": "Menarik kembali", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Manajer Host", - "hosts": "Tuan rumah", "sshHosts": "Host SSH", "noHosts": "Tidak ada Host SSH", "noHostsMessage": "Anda belum menambahkan host SSH apa pun. Klik \"Tambah Host\" untuk memulai.", @@ -846,7 +836,7 @@ "failedToImportJson": "Gagal mengimpor file JSON", "connectionDetails": "Detail Koneksi", "organization": "Organisasi", - "ipAddress": "Alamat IP atau Nama Host", + "ipAddress": "Alamat IP", "port": "Pelabuhan", "name": "Nama", "username": "Nama belakang", @@ -925,8 +915,6 @@ "key": "Kunci", "credential": "Mandat", "none": "Tidak ada", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Meminta Anda untuk memasukkan otentikasi web OPKSSH setiap kali terhubung. Token otentikasi Anda akan berlaku selama 24 jam sebelum perlu diperbarui.", "selectCredential": "Pilih Kredensial", "selectCredentialPlaceholder": "Pilih sertifikasi...", "credentialRequired": "Kredensial diperlukan saat menggunakan autentikasi kredensial.", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Otentikasi Interaktif Keyboard", "noneAuthDescription": "Metode autentikasi ini akan menggunakan autentikasi interaktif keyboard saat terhubung ke server SSH.", "noneAuthDetails": "Autentikasi interaktif keyboard memungkinkan server untuk meminta kredensial Anda selama koneksi. Ini berguna untuk server yang memerlukan autentikasi multi-faktor atau jika Anda tidak ingin menyimpan kredensial secara lokal.", - "opksshAuthTitle": "Otentikasi OPKSSH", "forceKeyboardInteractive": "Paksa Interaktif Keyboard", "forceKeyboardInteractiveDesc": "Memaksa penggunaan autentikasi interaktif keyboard. Ini terkadang diperlukan untuk server yang menggunakan Autentikasi Dua Faktor (TOTP/2FA).", "overrideCredentialUsername": "Ganti Nama Pengguna Kredensial", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Buka di Browser", "warpgateContinue": "Saya telah menyelesaikan otentikasi.", "warpgateTimeout": "Otentikasi Warpgate habis waktu. Silakan sambungkan kembali.", - "opksshAuthRequired": "Otentikasi OPKSSH Diperlukan", - "opksshAuthDescription": "Lakukan otentikasi di browser Anda untuk melanjutkan. Sesi ini akan tetap berlaku selama 24 jam.", - "opksshAuthUrl": "URL Otentikasi", - "opksshOpenBrowser": "Buka Browser untuk Melakukan Otentikasi", - "opksshWaitingForAuth": "Menunggu otentikasi di browser...", - "opksshAuthenticating": "Memproses otentikasi...", - "opksshTimeout": "Autentikasi habis waktu. Silakan coba lagi.", - "opksshAuthFailed": "Autentikasi gagal. Silakan periksa kredensial Anda dan coba lagi.", - "opksshConfigMissing": "Konfigurasi OPKSSH tidak ditemukan. Silakan buat ~/.opk/config.yml dengan pengaturan penyedia OIDC Anda. Lihat dokumentasi: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Masukkan kata sandi?", "sudoPasswordPopupHint": "Tekan Enter untuk memasukkan, Esc untuk menutup.", "sudoPasswordPopupConfirm": "Menyisipkan", @@ -1359,8 +1337,7 @@ "automaticFallback": "Mencoba otentikasi {{method}} secara otomatis...", "totpTimeout": "Waktu verifikasi TOTP habis. Silakan sambungkan kembali.", "passwordTimeout": "Waktu verifikasi kata sandi habis. Silakan sambungkan kembali.", - "connectionRejected": "Koneksi ditolak oleh server. Harap periksa otentikasi dan konfigurasi jaringan Anda.", - "hostKeyRejected": "Verifikasi kunci host SSH ditolak. Koneksi dibatalkan." + "connectionRejected": "Koneksi ditolak oleh server. Harap periksa otentikasi dan konfigurasi jaringan Anda." }, "fileManager": { "title": "Pengelola File", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Reset Kata Sandi Berhasil", "passwordResetSuccessDesc": "Kata sandi Anda telah berhasil direset. Anda sekarang dapat masuk dengan kata sandi baru Anda." }, - "hostKey": { - "verifyNewHost": "Verifikasi Kunci Host SSH", - "keyChangedWarning": "Kunci Host SSH Berubah", - "firstConnectionTitle": "Ini adalah kali pertama terhubung ke host ini.", - "firstConnectionDescription": "Keaslian host ini tidak dapat dipastikan. Verifikasi apakah sidik jari sesuai dengan yang Anda harapkan.", - "keyChangedDescription": "Kunci host untuk server ini telah berubah sejak koneksi terakhir Anda. Ini bisa mengindikasikan masalah keamanan.", - "previousKey": "Kunci Sebelumnya", - "newFingerprint": "Sidik Jari Baru", - "fingerprint": "Sidik jari", - "verifyInstructions": "Jika Anda mempercayai host ini, klik Terima untuk melanjutkan dan simpan sidik jari ini untuk koneksi di masa mendatang.", - "securityWarning": "Peringatan Keamanan", - "acceptAndContinue": "Terima & Lanjutkan", - "acceptNewKey": "Terima Kunci Baru & Lanjutkan" - }, "errors": { "notFound": "Halaman tidak ditemukan", "unauthorized": "Akses tidak sah", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Host & Sidebar", "snippetsSettings": "Cuplikan", - "confirmSnippetExecution": "Konfirmasi Eksekusi Cuplikan", - "confirmSnippetExecutionDesc": "Tampilkan dialog konfirmasi sebelum menjalankan cuplikan kode.", "updateSettings": "Pembaruan", "disableUpdateCheck": "Nonaktifkan Pemeriksaan Pembaruan", "disableUpdateCheckDesc": "Hentikan pengecekan versi baru saat startup dan dasbor. Mengurangi permintaan jaringan.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 atau example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/it_IT.json b/src/locales/translated/it_IT.json index 82d28344..33bf53f9 100644 --- a/src/locales/translated/it_IT.json +++ b/src/locales/translated/it_IT.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Credenziali", "credentialsViewer": "Visualizzatore Di Credenziali", "manageYourSSHCredentials": "Gestisci le tue credenziali SSH in modo sicuro", "addCredential": "Aggiungi Credenziali", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Questo aggiungerà la chiave pubblica al file ~/.ssh/authorized_keys dell'host di destinazione senza sovrascrivere le chiavi esistenti. L'operazione è reversibile.", "chooseHostToDeploy": "Scegli un host in cui distribuire...", "deploying": "Distribuzione...", - "copyDeployCommand": "Copia Il Comando Di Deploy", - "copyDeployCommandDescription": "Copia un comando shell per aggiungere manualmente la chiave pubblica al file authorized_keys di un host. Utile quando l'autenticazione con password è disabilitata.", - "deployCommandCopied": "Distribuisci comando copiato negli appunti", - "manualDeployInfo": "Incolla questo comando nel terminale dell'host di destinazione (ad esempio, console Proxmox, IPMI o accesso fisico) per aggiungere il tasto SSH.", "name": "Nome", "noHostsAvailable": "Nessun host disponibile", "noHostsMatchSearch": "Nessun host corrisponde alla tua ricerca", @@ -303,9 +298,7 @@ "createFolder": "Crea Cartella", "editFolder": "Modifica Cartella", "editFolderDescription": "Personalizza la cartella snippet", - "createFolderDescription": "Organizza i pezzetti di codice nelle cartelle", - "confirmExecution": "Eseguire \"{{name}}\"?", - "confirmExecutionDesc": "Sei sicuro di voler eseguire questa snippet?" + "createFolderDescription": "Organizza i pezzetti di codice nelle cartelle" }, "commandHistory": { "title": "Storico", @@ -409,7 +402,6 @@ "required": "Richiesto", "optional": "Facoltativo", "connect": "Connetti", - "copied": "Copiato", "connecting": "Connessione...", "creating": "Creazione...", "clear": "Pulisci", @@ -502,7 +494,6 @@ "checking": "Controllo...", "checkingDatabase": "Controllo connessione database...", "checkingAuthentication": "Controllo autenticazione...", - "backendReconnected": "Connessione al server ripristinata", "actions": "Azioni", "remove": "Rimuovi", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gestore Host", - "hosts": "Host", "sshHosts": "Host SSH", "noHosts": "Nessun Host SSH", "noHostsMessage": "Non hai ancora aggiunto nessun host SSH. Clicca su \"Aggiungi host\" per iniziare.", @@ -846,7 +836,7 @@ "failedToImportJson": "Importazione del file JSON non riuscita", "connectionDetails": "Dettagli Della Connessione", "organization": "Organizzazione", - "ipAddress": "Indirizzo IP o nome host", + "ipAddress": "Indirizzo IP", "port": "Porta", "name": "Nome", "username": "Username", @@ -925,8 +915,6 @@ "key": "Chiave", "credential": "Credenziali", "none": "Nessuno", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Ti consente di eseguire il web-auth di OPKSSH su ogni connessione. Il tuo token di autenticazione durerà 24 ore fino a quando deve essere rinnovato.", "selectCredential": "Seleziona Credenziali", "selectCredentialPlaceholder": "Scegli una credenziale...", "credentialRequired": "La credenziale è richiesta quando si utilizza l'autenticazione credenziali", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autenticazione Tastiera-Interattiva", "noneAuthDescription": "Questo metodo di autenticazione utilizzerà l'autenticazione interattiva con tastiera durante la connessione al server SSH.", "noneAuthDetails": "L'autenticazione interattiva con tastiera consente al server di richiedere le credenziali durante la connessione. Questo è utile per i server che richiedono l'autenticazione a più fattori o se non si desidera salvare le credenziali localmente.", - "opksshAuthTitle": "Autenticazione OPKSSH", "forceKeyboardInteractive": "Forza Tastiera-Interattiva", "forceKeyboardInteractiveDesc": "Forza l'uso dell'autenticazione interattiva con tastiera. A volte è necessario per i server che utilizzano Autenticazione a due fattori (TOTP/2FA).", "overrideCredentialUsername": "Ignora Il Nome Utente Credenziale", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Apri nel browser", "warpgateContinue": "Ho Completato L'Autenticazione", "warpgateTimeout": "Timeout autenticazione Warpgate. Per favore riconnetterti.", - "opksshAuthRequired": "Richiesta Autenticazione OPKSSH", - "opksshAuthDescription": "Completa l'autenticazione nel tuo browser per continuare. Questa sessione rimarrà valida per 24 ore.", - "opksshAuthUrl": "Url Di Autenticazione", - "opksshOpenBrowser": "Apri Browser per Autenticare", - "opksshWaitingForAuth": "In attesa di autenticazione nel browser...", - "opksshAuthenticating": "Elaborazione autenticazione...", - "opksshTimeout": "Autenticazione scaduta. Riprova.", - "opksshAuthFailed": "Autenticazione non riuscita. Controlla le tue credenziali e riprova.", - "opksshConfigMissing": "Configurazione OPKSSH non trovata. Si prega di creare ~/.opk/config.yml con le impostazioni del provider OIDC. Vedi documentazione: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Inserire Password?", "sudoPasswordPopupHint": "Premere Invio per inserire, Esc per eliminare", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Prova automaticamente l'autenticazione {{method}}...", "totpTimeout": "Timeout verifica TOTP. Riconnettersi.", "passwordTimeout": "Timeout verifica password. Si prega di riconnettersi.", - "connectionRejected": "Connessione rifiutata dal server. Controlla la tua autenticazione e configurazione di rete.", - "hostKeyRejected": "Verifica della chiave host SSH rifiutata. Connessione annullata." + "connectionRejected": "Connessione rifiutata dal server. Controlla la tua autenticazione e configurazione di rete." }, "fileManager": { "title": "Gestore File", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Reset Password Con Successo", "passwordResetSuccessDesc": "La password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password." }, - "hostKey": { - "verifyNewHost": "Verifica La Chiave Host Ssh", - "keyChangedWarning": "Chiave Host Ssh Cambiata", - "firstConnectionTitle": "Prima volta che ci si connette a questo host", - "firstConnectionDescription": "L'autenticità di questo host non può essere stabilita. Verifica che l'impronta digitale corrisponda a quanto ci si aspetta.", - "keyChangedDescription": "La chiave host di questo server è cambiata dall'ultima connessione. Questo potrebbe indicare un problema di sicurezza.", - "previousKey": "Chiave Precedente", - "newFingerprint": "Nuova Impronta Digitale", - "fingerprint": "Impronta", - "verifyInstructions": "Se ti fidi di questo host, fai clic su Accetta per continuare e salvare questa impronta digitale per le connessioni future.", - "securityWarning": "Avviso Di Sicurezza", - "acceptAndContinue": "Accetta E Continua", - "acceptNewKey": "Accetta La Nuova Chiave E Continua" - }, "errors": { "notFound": "Pagina non trovata", "unauthorized": "Accesso non autorizzato", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminale", "hostSidebarSettings": "& Barra Laterale Dell'Host", "snippetsSettings": "Snippet", - "confirmSnippetExecution": "Conferma Esecuzione Snippet", - "confirmSnippetExecutionDesc": "Mostra la finestra di conferma prima di eseguire snippet", "updateSettings": "Aggiornamenti", "disableUpdateCheck": "Disabilita Controllo Aggiornamenti", "disableUpdateCheckDesc": "Smetti di controllare nuove versioni all'avvio e alla dashboard. Riduce le richieste di rete.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 o example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/ja_JP.json b/src/locales/translated/ja_JP.json index 05a5773e..28024b27 100644 --- a/src/locales/translated/ja_JP.json +++ b/src/locales/translated/ja_JP.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "資格情報", "credentialsViewer": "資格情報ビューアー", "manageYourSSHCredentials": "SSH認証情報を安全に管理します", "addCredential": "証明書の追加", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "これにより、既存の鍵を上書きすることなく、公開鍵をホストの ~/.ssh/authorized_keys ファイルに安全に追加します。操作は取り消し可能です。", "chooseHostToDeploy": "デプロイするホストを選択してください...", "deploying": "デプロイ中...", - "copyDeployCommand": "デプロイコマンドをコピー", - "copyDeployCommandDescription": "シェルコマンドをコピーして、ホストの authorized_keys ファイルに公開鍵を手動で追加します。パスワード認証が無効になっている場合に便利です。", - "deployCommandCopied": "デプロイコマンドがクリップボードにコピーされました", - "manualDeployInfo": "SSHキーを追加するには、このコマンドをターゲットホストの端末(例:Proxmoxコンソール、IPMI、または物理アクセス)に貼り付けます。", "name": "名前", "noHostsAvailable": "利用可能なホストがありません", "noHostsMatchSearch": "検索条件に一致するホストがありません", @@ -303,9 +298,7 @@ "createFolder": "フォルダを作成", "editFolder": "フォルダを編集", "editFolderDescription": "スニペットフォルダをカスタマイズ", - "createFolderDescription": "スニペットをフォルダーに整理する", - "confirmExecution": "「{{name}}」を実行しますか?", - "confirmExecutionDesc": "このスニペットを実行してもよろしいですか?" + "createFolderDescription": "スニペットをフォルダーに整理する" }, "commandHistory": { "title": "沿革", @@ -409,7 +402,6 @@ "required": "必須", "optional": "省略可能", "connect": "接続する", - "copied": "コピーしました", "connecting": "接続中...", "creating": "作成中...", "clear": "クリア", @@ -502,7 +494,6 @@ "checking": "確認しています...", "checkingDatabase": "データベース接続を確認しています...", "checkingAuthentication": "認証中char@@0", - "backendReconnected": "サーバー接続が復元されました", "actions": "アクション", "remove": "削除", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "ホスト マネージャー", - "hosts": "ホスト", "sshHosts": "SSH ホスト", "noHosts": "SSHホストがありません", "noHostsMessage": "SSHホストはまだ追加されていません。「ホストを追加」をクリックして開始してください。", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON ファイルのインポートに失敗しました", "connectionDetails": "接続詳細", "organization": "組織", - "ipAddress": "IPアドレスまたはホスト名", + "ipAddress": "IP アドレス", "port": "ポート", "name": "名前", "username": "ユーザー名", @@ -925,8 +915,6 @@ "key": "キー", "credential": "資格情報", "none": "なし", - "opkssh": "OPKSSH", - "opksshAuthDescription": "接続ごとにOPKSSHのweb-authを表示します。認証トークンは更新されるまで24時間持続します。", "selectCredential": "証明書を選択", "selectCredentialPlaceholder": "資格情報を選択...", "credentialRequired": "資格情報認証を使用するには認証情報が必要です", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "キーボード対話型認証", "noneAuthDescription": "この認証方法は、SSH サーバーに接続する際にキーボード対話型認証を使用します。", "noneAuthDetails": "キーボード対話型認証により、サーバーは接続中に資格情報の入力を求めることができます。 これは、多要素認証を必要とするサーバーや、ローカルで資格情報を保存したくないサーバーに便利です。", - "opksshAuthTitle": "OPKSSH 認証", "forceKeyboardInteractive": "キーボード対話を強制する", "forceKeyboardInteractiveDesc": "2要素認証(TOTP/2FA)を使用するサーバーでは、キーボード対話型認証を強制的に使用します。", "overrideCredentialUsername": "認証情報のユーザー名を上書きする", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "ブラウザで開く", "warpgateContinue": "認証を完了しました", "warpgateTimeout": "ワープゲート認証がタイムアウトしました。再接続してください。", - "opksshAuthRequired": "OPKSSH 認証が必要です", - "opksshAuthDescription": "ブラウザで認証を完了してください。このセッションは24時間有効です。", - "opksshAuthUrl": "認証URL", - "opksshOpenBrowser": "ブラウザを開いて認証する", - "opksshWaitingForAuth": "ブラウザでの認証を待っています...", - "opksshAuthenticating": "認証を処理中...", - "opksshTimeout": "認証がタイムアウトしました。もう一度やり直してください。", - "opksshAuthFailed": "認証に失敗しました。資格情報を確認して、もう一度やり直してください。", - "opksshConfigMissing": "OPKSSH設定が見つかりません。OIDCプロバイダ設定で~/.opk/config.ymlを作成してください。ドキュメントを参照してください: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "パスワードを入力してください。", "sudoPasswordPopupHint": "Enterキーを押して挿入、Escキーで閉じる", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "{{method}} 認証を自動的に試みています...", "totpTimeout": "TOTP 認証がタイムアウトしました。再接続してください。", "passwordTimeout": "パスワードの検証がタイムアウトしました。再接続してください。", - "connectionRejected": "サーバーによって接続が拒否されました。認証とネットワーク設定を確認してください。", - "hostKeyRejected": "SSHホスト鍵の検証が拒否されました。接続がキャンセルされました。" + "connectionRejected": "サーバーによって接続が拒否されました。認証とネットワーク設定を確認してください。" }, "fileManager": { "title": "ファイルマネージャー", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "パスワードをリセットしました", "passwordResetSuccessDesc": "パスワードが正常にリセットされました。新しいパスワードでログインできるようになりました。" }, - "hostKey": { - "verifyNewHost": "SSHホストキーを確認", - "keyChangedWarning": "SSH ホストキーが変更されました", - "firstConnectionTitle": "初めてこのホストに接続しました", - "firstConnectionDescription": "このホストの信頼性を確立できません。指紋が期待されているものと一致することを確認してください。", - "keyChangedDescription": "このサーバーのホストキーは、最後の接続から変更されました。これはセキュリティ上の問題を示している可能性があります。", - "previousKey": "前のキー", - "newFingerprint": "新しいフィンガープリント", - "fingerprint": "フィンガープリント", - "verifyInstructions": "このホストを信頼している場合は、format@@0をクリックして続行し、今後の接続のためにこの指紋を保存します。", - "securityWarning": "セキュリティ警告", - "acceptAndContinue": "承認して続ける", - "acceptNewKey": "新しいキーを承認して続ける" - }, "errors": { "notFound": "ページが見つかりません", "unauthorized": "権限のないアクセス", @@ -2063,8 +2026,6 @@ "terminalSettings": "ターミナル", "hostSidebarSettings": "ホスト & サイドバー", "snippetsSettings": "Snippets", - "confirmSnippetExecution": "スニペットの実行を確認", - "confirmSnippetExecutionDesc": "スニペットを実行する前に確認ダイアログを表示する", "updateSettings": "更新", "disableUpdateCheck": "アップデートチェックを無効にする", "disableUpdateCheckDesc": "起動時とダッシュボードで新しいバージョンの確認を停止します。ネットワークリクエストを削減します。", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1またはexample.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/ko_KR.json b/src/locales/translated/ko_KR.json index a6eaac90..d7649caf 100644 --- a/src/locales/translated/ko_KR.json +++ b/src/locales/translated/ko_KR.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "신임장", "credentialsViewer": "자격 증명 뷰어", "manageYourSSHCredentials": "SSH 자격 증명을 안전하게 관리하세요", "addCredential": "자격 증명 추가", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "이렇게 하면 기존 키를 덮어쓰지 않고 대상 호스트의 ~/.ssh/authorized_keys 파일에 공개 키가 안전하게 추가됩니다. 이 작업은 되돌릴 수 있습니다.", "chooseHostToDeploy": "배포할 호스트를 선택하세요...", "deploying": "배포 중...", - "copyDeployCommand": "배포 명령 복사", - "copyDeployCommandDescription": "호스트의 authorized_keys 파일에 공개 키를 수동으로 추가하는 셸 명령어를 복사합니다. 암호 인증이 비활성화된 경우 유용합니다.", - "deployCommandCopied": "배포 명령어가 클립보드에 복사되었습니다.", - "manualDeployInfo": "대상 호스트의 터미널(예: Proxmox 콘솔, IPMI 또는 물리적 접근)에 다음 명령어를 붙여넣어 SSH 키를 추가하십시오.", "name": "이름", "noHostsAvailable": "호스트를 사용할 수 없습니다.", "noHostsMatchSearch": "검색 조건에 맞는 호스트가 없습니다.", @@ -303,9 +298,7 @@ "createFolder": "폴더 생성", "editFolder": "폴더 편집", "editFolderDescription": "스니펫 폴더를 사용자 지정하세요", - "createFolderDescription": "메모들을 폴더별로 정리하세요", - "confirmExecution": "\"{{name}}\"을 실행하시겠습니까?", - "confirmExecutionDesc": "이 코드 조각을 실행하시겠습니까?" + "createFolderDescription": "메모들을 폴더별로 정리하세요" }, "commandHistory": { "title": "역사", @@ -409,7 +402,6 @@ "required": "필수의", "optional": "선택 과목", "connect": "연결하다", - "copied": "복사됨", "connecting": "연결 중...", "creating": "생성 중...", "clear": "분명한", @@ -502,7 +494,6 @@ "checking": "확인 중...", "checkingDatabase": "데이터베이스 연결을 확인하는 중...", "checkingAuthentication": "인증 상태를 확인하는 중...", - "backendReconnected": "서버 연결이 복구되었습니다.", "actions": "행위", "remove": "제거하다", "revoke": "취소", @@ -815,7 +806,6 @@ }, "hosts": { "title": "호스트 관리자", - "hosts": "호스트", "sshHosts": "SSH 호스트", "noHosts": "SSH 호스트 없음", "noHostsMessage": "아직 SSH 호스트를 추가하지 않았습니다. 시작하려면 \"호스트 추가\"를 클릭하세요.", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON 파일을 가져오는 데 실패했습니다.", "connectionDetails": "연결 정보", "organization": "조직", - "ipAddress": "IP 주소 또는 호스트 이름", + "ipAddress": "IP 주소", "port": "포트", "name": "이름", "username": "사용자 이름", @@ -925,8 +915,6 @@ "key": "열쇠", "credential": "신임장", "none": "없음", - "opkssh": "오프크시", - "opksshAuthDescription": "접속할 때마다 OPKSSH 웹 인증을 요청합니다. 인증 토큰은 24시간 동안 유효하며, 갱신해야 합니다.", "selectCredential": "자격증명을 선택하세요", "selectCredentialPlaceholder": "자격증을 선택하세요...", "credentialRequired": "자격 증명 인증을 사용할 때는 자격 증명이 필요합니다.", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "키보드 인터랙티브 인증", "noneAuthDescription": "이 인증 방식은 SSH 서버에 연결할 때 키보드 대화형 인증을 사용합니다.", "noneAuthDetails": "키보드 대화형 인증을 사용하면 서버에 연결하는 동안 자격 증명을 입력하라는 메시지가 표시됩니다. 이는 다단계 인증이 필요한 서버나 자격 증명을 로컬에 저장하지 않으려는 경우에 유용합니다.", - "opksshAuthTitle": "OPKSSH 인증", "forceKeyboardInteractive": "키보드 상호작용 강제 적용", "forceKeyboardInteractiveDesc": "키보드 입력 방식의 인증을 강제로 사용하게 합니다. 이는 2단계 인증(TOTP/2FA)을 사용하는 서버에서 필요한 경우가 있습니다.", "overrideCredentialUsername": "자격 증명 사용자 이름 재정의", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "브라우저에서 열기", "warpgateContinue": "인증이 완료되었습니다", "warpgateTimeout": "워프게이트 인증 시간 초과입니다. 다시 연결해 주세요.", - "opksshAuthRequired": "OPKSSH 인증이 필요합니다", - "opksshAuthDescription": "계속하려면 브라우저에서 인증을 완료하세요. 이 세션은 24시간 동안 유효합니다.", - "opksshAuthUrl": "인증 URL", - "opksshOpenBrowser": "인증하려면 브라우저를 여세요", - "opksshWaitingForAuth": "브라우저에서 인증을 기다리는 중...", - "opksshAuthenticating": "인증 처리 중...", - "opksshTimeout": "인증 시간이 초과되었습니다. 다시 시도해 주세요.", - "opksshAuthFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도해 주세요.", - "opksshConfigMissing": "OPKSSH 구성 파일을 찾을 수 없습니다. OIDC 공급자 설정이 포함된 ~/.opk/config.yml 파일을 생성하십시오. 자세한 내용은 다음 문서를 참조하십시오. https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "비밀번호를 입력하시겠습니까?", "sudoPasswordPopupHint": "Enter 키를 눌러 삽입하고, Esc 키를 눌러 닫습니다.", "sudoPasswordPopupConfirm": "끼워 넣다", @@ -1359,8 +1337,7 @@ "automaticFallback": "{{method}} 인증을 자동으로 시도합니다...", "totpTimeout": "TOTP 인증 시간이 초과되었습니다. 다시 연결해 주세요.", "passwordTimeout": "비밀번호 인증 시간이 초과되었습니다. 다시 연결해 주세요.", - "connectionRejected": "서버에서 연결이 거부되었습니다. 인증 및 네트워크 구성을 확인하십시오.", - "hostKeyRejected": "SSH 호스트 키 인증이 거부되었습니다. 연결이 취소되었습니다." + "connectionRejected": "서버에서 연결이 거부되었습니다. 인증 및 네트워크 구성을 확인하십시오." }, "fileManager": { "title": "파일 관리자", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "비밀번호 재설정 성공", "passwordResetSuccessDesc": "비밀번호가 성공적으로 재설정되었습니다. 이제 새 비밀번호로 로그인할 수 있습니다." }, - "hostKey": { - "verifyNewHost": "SSH 호스트 키 확인", - "keyChangedWarning": "SSH 호스트 키가 변경되었습니다.", - "firstConnectionTitle": "이 호스트에 처음 연결합니다.", - "firstConnectionDescription": "이 호스트의 진위 여부를 확인할 수 없습니다. 지문이 예상과 일치하는지 확인하십시오.", - "keyChangedDescription": "이 서버의 호스트 키가 마지막 접속 이후 변경되었습니다. 이는 보안 문제를 나타낼 수 있습니다.", - "previousKey": "이전 키", - "newFingerprint": "새로운 지문", - "fingerprint": "지문", - "verifyInstructions": "이 호스트를 신뢰한다면 '수락'을 클릭하여 계속 진행하고 향후 연결을 위해 이 지문을 저장하세요.", - "securityWarning": "보안 경고", - "acceptAndContinue": "수락 및 계속", - "acceptNewKey": "새 키를 수락하고 계속 진행하세요" - }, "errors": { "notFound": "페이지를 찾을 수 없습니다", "unauthorized": "무단 접근", @@ -2063,8 +2026,6 @@ "terminalSettings": "터미널", "hostSidebarSettings": "호스트 및 사이드바", "snippetsSettings": "스니펫", - "confirmSnippetExecution": "스니펫 실행 확인", - "confirmSnippetExecutionDesc": "코드 조각 실행 전에 확인 대화 상자를 표시합니다.", "updateSettings": "업데이트", "disableUpdateCheck": "업데이트 확인 비활성화", "disableUpdateCheckDesc": "시작 시 및 대시보드에서 새 버전 확인을 중지합니다. 네트워크 요청이 줄어듭니다.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 또는 example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/nl_NL.json b/src/locales/translated/nl_NL.json index 4eba9384..b4b9d5e5 100644 --- a/src/locales/translated/nl_NL.json +++ b/src/locales/translated/nl_NL.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Aanmeldgegevens", "credentialsViewer": "Referenties bekijken", "manageYourSSHCredentials": "Beheer uw SSH inloggegevens veilig", "addCredential": "Toegangsgegevens toevoegen", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Dit zal veilig de publieke sleutel toevoegen aan het doel host's ~/.ssh/authorized_keys bestand zonder bestaande sleutels te overschrijven. De bewerking is omkeerbaar.", "chooseHostToDeploy": "Kies een host om naar te implementeren...", "deploying": "Implementeren...", - "copyDeployCommand": "Kopieer implementatieopdracht", - "copyDeployCommandDescription": "Kopieer een shell commando om handmatig de publieke sleutel toe te voegen aan het authorized_keys bestand van een host. Handig wanneer wachtwoordauthenticatie is uitgeschakeld.", - "deployCommandCopied": "Implementeer opdracht gekopieerd naar klembord", - "manualDeployInfo": "Plak deze opdracht in de terminal van de doel host (bijv. Proxmox console, IPMI of fysieke toegang) om de SSH sleutel toe te voegen.", "name": "naam", "noHostsAvailable": "Geen hosts beschikbaar", "noHostsMatchSearch": "Er zijn geen hosts die voldoen aan jouw zoekopdracht", @@ -303,9 +298,7 @@ "createFolder": "Map aanmaken", "editFolder": "Map bewerken", "editFolderDescription": "Tekstbouwstenenmap aanpassen", - "createFolderDescription": "Organiseer uw snippets in mappen", - "confirmExecution": "Uitvoeren \"{{name}}\"?", - "confirmExecutionDesc": "Weet u zeker dat u deze snippet wilt uitvoeren?" + "createFolderDescription": "Organiseer uw snippets in mappen" }, "commandHistory": { "title": "Geschiedenis", @@ -409,7 +402,6 @@ "required": "vereist", "optional": "Optioneel", "connect": "Verbinden", - "copied": "Gekoppeld", "connecting": "Verbinden...", "creating": "Maken...", "clear": "Verwijderen", @@ -502,7 +494,6 @@ "checking": "Controleren...", "checkingDatabase": "Database-verbinding controleren...", "checkingAuthentication": "Authenticatie controleren...", - "backendReconnected": "Serververbinding hersteld", "actions": "acties", "remove": "Verwijderen", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Host Manager", - "hosts": "Verantwoordelijken", "sshHosts": "SSH hosts", "noHosts": "Geen SSH hosts", "noHostsMessage": "U heeft nog geen SSH hosts toegevoegd. Klik op \"Host toevoegen\" om te beginnen.", @@ -846,7 +836,7 @@ "failedToImportJson": "Kan JSON bestand niet importeren", "connectionDetails": "Connectie Details", "organization": "Rekening", - "ipAddress": "IP-adres of hostnaam", + "ipAddress": "IP adres", "port": "Poort", "name": "naam", "username": "Gebruikersnaam", @@ -925,8 +915,6 @@ "key": "Sleutel", "credential": "Toegangsgegevens", "none": "geen", - "opkssh": "OPKENEN", - "opksshAuthDescription": "Verzoekt u naar OPKSSH web-authenticatie bij elke verbinding. Uw autorisatietoken zal 24 uur duren tot het moet worden vernieuwd.", "selectCredential": "Selecteer referenties", "selectCredentialPlaceholder": "Kies een inloggegevens...", "credentialRequired": "Referentiegegevens zijn vereist bij het gebruik van referentieauthenticatie", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Toetsenbord-Interactieve authenticatie", "noneAuthDescription": "Deze verificatiemethode gebruikt keyboard-interactieve authenticatie bij het verbinden met de SSH-server.", "noneAuthDetails": "Keyboard-interactieve authenticatie maakt het mogelijk dat de server je om inloggegevens vraagt tijdens het verbinden. Dit is handig voor servers waarvoor multi-factor authenticatie vereist is of als u geen lokale inloggegevens wilt opslaan.", - "opksshAuthTitle": "OPKSSH authenticatie", "forceKeyboardInteractive": "Forceer toetsenbord-interactief", "forceKeyboardInteractiveDesc": "Forceert het gebruik van keyboard-interactieve authenticatie. Dit is soms vereist voor servers die gebruik maken van tweestapsverificatie (TOTP/2FA).", "overrideCredentialUsername": "Aanmeldgebruikersnaam overschrijven", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Open in browser", "warpgateContinue": "Ik heb authenticatie voltooid", "warpgateTimeout": "Warpgate authenticatie time-out. Maak opnieuw verbinding.", - "opksshAuthRequired": "OPKSSH authenticatie vereist", - "opksshAuthDescription": "Voltooi de authenticatie in je browser om door te gaan. Deze sessie blijft 24 uur geldig.", - "opksshAuthUrl": "URL authenticatie", - "opksshOpenBrowser": "Browser openen om te verifiëren", - "opksshWaitingForAuth": "Wachten op authenticatie in browser...", - "opksshAuthenticating": "Authenticatie verwerken...", - "opksshTimeout": "Authenticatie is verlopen. Probeer het opnieuw.", - "opksshAuthFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.", - "opksshConfigMissing": "OPKSSH configuratie niet gevonden. Maak ~/.opk/config.yml aan met uw OIDC providerinstellingen. Zie documentatie: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Wachtwoord invoeren?", "sudoPasswordPopupHint": "Druk op Enter om in te voegen, Esc om te sluiten", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Authenticatie {{method}} automatisch proberen...", "totpTimeout": "TOTP-verificatie time-out. Gelieve opnieuw te verbinden.", "passwordTimeout": "Wachtwoord verificatie time-out. Gelieve opnieuw te verbinden.", - "connectionRejected": "Verbinding geweigerd door de server. Controleer uw authenticatie en netwerkconfiguratie.", - "hostKeyRejected": "SSH host sleutel verificatie afgewezen. Verbinding geannuleerd." + "connectionRejected": "Verbinding geweigerd door de server. Controleer uw authenticatie en netwerkconfiguratie." }, "fileManager": { "title": "Bestands Beheer", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Wachtwoord opnieuw instellen geslaagd", "passwordResetSuccessDesc": "Uw wachtwoord is met succes gereset. U kunt nu inloggen met uw nieuwe wachtwoord." }, - "hostKey": { - "verifyNewHost": "Verifieer SSH host-sleutel", - "keyChangedWarning": "SSH host-sleutel gewijzigd", - "firstConnectionTitle": "Eerste keer verbinden met deze host", - "firstConnectionDescription": "De authenticiteit van deze host kan niet worden vastgesteld. Controleer of de vingerafdruk overeenkomt met uw verwachting.", - "keyChangedDescription": "De hostsleutel voor deze server is veranderd sinds je laatste verbinding. Dit kan duiden op een beveiligingsprobleem.", - "previousKey": "Vorige sleutel", - "newFingerprint": "Nieuwe vingerafdruk", - "fingerprint": "Vingerafdruk", - "verifyInstructions": "Als je deze host vertrouwt, klik je op Accepteren om door te gaan en sla deze vingerafdruk op voor toekomstige verbindingen.", - "securityWarning": "Waarschuwing beveiliging", - "acceptAndContinue": "Accepteren & doorgaan", - "acceptNewKey": "Accepteer Nieuwe Sleutel & Doorgaan" - }, "errors": { "notFound": "Pagina niet gevonden", "unauthorized": "Onbevoegde toegang", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Host & Sidebar", "snippetsSettings": "Tekstbouwstenen", - "confirmSnippetExecution": "Bevestig de uitvoering van de Snippet", - "confirmSnippetExecutionDesc": "Toon bevestigingsvenster voor het uitvoeren van snippets", "updateSettings": "Bijwerken", "disableUpdateCheck": "Updatecontrole uitschakelen", "disableUpdateCheckDesc": "Stop met controleren op nieuwe versies bij opstarten en dashboard. Vermindert netwerkaanvragen.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 of voorbeeld.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/no_NO.json b/src/locales/translated/no_NO.json index 31e60028..cb4733c4 100644 --- a/src/locales/translated/no_NO.json +++ b/src/locales/translated/no_NO.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Legitimasjon", "credentialsViewer": "Legitimasjonsvisning", "manageYourSSHCredentials": "Administrer SSH-legitimasjon sikkert", "addCredential": "Legg til legitimasjon", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Dette legger den offentlige nøkkelen trygt til målserverens ~/.ssh/authorized_keys uten å overskrive eksisterende nøkler. Operasjonen kan reverseres.", "chooseHostToDeploy": "Velg en vert å distribuere til...", "deploying": "Distribuerer...", - "copyDeployCommand": "Kopier distribueringskommandoen", - "copyDeployCommandDescription": "Kopier en skallkommando for å legge den offentlige nøkkelen til en verts autorisert_key-fil. Nyttig når passordautentisering er deaktivert.", - "deployCommandCopied": "Distribuer kommandoen kopiert til utklippstavlen", - "manualDeployInfo": "Lim inn denne kommandoen i målvertens terminal (f.eks. Proxmox konsoll, IPMI eller fysisk tilgang) for å legge til SSH-nøkkelen.", "name": "Navn", "noHostsAvailable": "Ingen verter tilgjengelig", "noHostsMatchSearch": "Ingen verter samsvarer med søket", @@ -303,9 +298,7 @@ "createFolder": "Opprett mappe", "editFolder": "Rediger mappe", "editFolderDescription": "Tilpass utdragsmappen din", - "createFolderDescription": "Organiser snippeten dine i mapper", - "confirmExecution": "Utfør \"{{name}}\"?", - "confirmExecutionDesc": "Er du sikker på at du vil utføre dette snippet?" + "createFolderDescription": "Organiser snippeten dine i mapper" }, "commandHistory": { "title": "Historikk", @@ -409,7 +402,6 @@ "required": "Påkrevd", "optional": "Valgfritt", "connect": "Koble til", - "copied": "Kopiert", "connecting": "Kobler til...", "creating": "Oppretter...", "clear": "Tøm", @@ -502,7 +494,6 @@ "checking": "Kontrollerer...", "checkingDatabase": "Kontrollerer databaseforbindelse...", "checkingAuthentication": "Kontrollerer autentisering...", - "backendReconnected": "Servertilkobling gjenopprettet", "actions": "Handlinger", "remove": "Fjern", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Vertadministrator", - "hosts": "Verter", "sshHosts": "SSH-verter", "noHosts": "Ingen SSH-verter", "noHostsMessage": "Du har ikke lagt til noen SSH-verter ennå. Klikk \"Legg til vert\" for å begynne.", @@ -846,7 +836,7 @@ "failedToImportJson": "Kunne ikke importere JSON-fil", "connectionDetails": "Tilkoblingsdetaljer", "organization": "Organisasjon", - "ipAddress": "IP adresse eller vertsnavn", + "ipAddress": "IP-adresse", "port": "Port", "name": "Navn", "username": "Brukernavn", @@ -925,8 +915,6 @@ "key": "Nøkkel", "credential": "Legitimasjon", "none": "Ingen", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Ledetekster deg for OPKSSH web-auth ved hver tilkobling. Auth token varer i 24 timer til den må fornyes.", "selectCredential": "Velg legitimasjon", "selectCredentialPlaceholder": "Velg en legitimasjon...", "credentialRequired": "Legitimasjon kreves ved legitimasjonsautentisering", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Tastaturinteraktiv autentisering", "noneAuthDescription": "Denne autentiseringsmetoden bruker tastaturinteraktiv autentisering ved tilkobling til SSH-serveren.", "noneAuthDetails": "Tastaturinteraktiv autentisering lar serveren be deg om legitimasjon under tilkobling. Nyttig for servere som krever flerfaktorautentisering eller hvis du ikke vil lagre legitimasjon lokalt.", - "opksshAuthTitle": "OPKSSH godkjenning", "forceKeyboardInteractive": "Tving tastaturinteraktiv", "forceKeyboardInteractiveDesc": "Tving bruk av tastaturinteraktiv autentisering. Ofte nødvendig for servere som bruker tofaktorautentisering (TOTP/2FA).", "overrideCredentialUsername": "Overstyr brukernavn fra legitimasjon", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Åpne i nettleser", "warpgateContinue": "Jeg har fullført godkjenning", "warpgateTimeout": "Warpgate autentisering tidsavbrudd. Vennligst koble til på nytt.", - "opksshAuthRequired": "OPKSSH godkjenning kreves", - "opksshAuthDescription": "Fullstendig autentisering i nettleseren din for å fortsette. Denne økten er gyldig i 24 timer.", - "opksshAuthUrl": "Autentisering URL", - "opksshOpenBrowser": "Åpne nettleseren for å godkjenne", - "opksshWaitingForAuth": "Venter på godkjenning i nettleseren...", - "opksshAuthenticating": "Behandler autentisering...", - "opksshTimeout": "Autentisering ble tidsavbrutt. Vennligst prøv igjen.", - "opksshAuthFailed": "Godkjenning mislyktes. Kontroller påloggingsinformasjonen, og prøv på nytt.", - "opksshConfigMissing": "OPKSSH konfigurasjon ikke funnet. Lag ~/.opk/config.yml med dine OIDC leverandørinnstillinger. Se dokumentasjon: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Sette inn passord?", "sudoPasswordPopupHint": "Trykk på Enter for å sette inn, Esc for å fjerne", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Prøver automatisk {{method}} autentisering...", "totpTimeout": "TOTP-verifisering tidsavbrudd. Vennligst koble til på nytt.", "passwordTimeout": "Tidsavbrudd for bekreftelse av passord. Vennligst koble til på nytt.", - "connectionRejected": "Tilkobling avvist av serveren. Kontroller godkjennings- og nettverkskonfigurasjonen.", - "hostKeyRejected": "SSH vertsnøkkelverifisering ble avvist. Tilkoblingen avbrutt." + "connectionRejected": "Tilkobling avvist av serveren. Kontroller godkjennings- og nettverkskonfigurasjonen." }, "fileManager": { "title": "Filbehandler", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Tilbakestilling av passord vellykket", "passwordResetSuccessDesc": "Passordet er blitt tilbakestilt. Du kan nå logge inn med ditt nye passord." }, - "hostKey": { - "verifyNewHost": "Verifiser SSH vertsnøkkel", - "keyChangedWarning": "SSH vertsnøkkel endret", - "firstConnectionTitle": "Første gang du kobler til denne verten", - "firstConnectionDescription": "Vertens autensitet kan ikke etableres. Kontroller fingeravtrykket samsvarer med det du forventer.", - "keyChangedDescription": "Vertsnøkkelen for denne serveren er endret siden forrige tilkobling. Dette kan angi et sikkerhetsproblem.", - "previousKey": "Forrige nøkkel", - "newFingerprint": "Nytt fingeravtrykk", - "fingerprint": "Fingeravtrykk", - "verifyInstructions": "Hvis du stoler på denne verten, klikk Godta for å fortsette og lagre dette fingeravtrykket for fremtidige tilkoblinger.", - "securityWarning": "Sikkerhetsadvarsel", - "acceptAndContinue": "Godta og fortsett", - "acceptNewKey": "Aksepter ny nøkkel og fortsett" - }, "errors": { "notFound": "Siden ble ikke funnet", "unauthorized": "Uautorisert tilgang", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Verts- og sidepanelet", "snippetsSettings": "Snippets", - "confirmSnippetExecution": "Bekreft kjøring av bruddtekst", - "confirmSnippetExecutionDesc": "Vis bekreftelsesdialog før du utfører snippets", "updateSettings": "Oppdateringer", "disableUpdateCheck": "Deaktiver oppdateringskontroll", "disableUpdateCheckDesc": "Slutt å sjekke nye versjoner ved oppstart og dashbord. Reduserer nettverksforespørsler.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 eller eksempel.no", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/pl_PL.json b/src/locales/translated/pl_PL.json index 86838aed..739189df 100644 --- a/src/locales/translated/pl_PL.json +++ b/src/locales/translated/pl_PL.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Dane logowania", "credentialsViewer": "Przeglądarka danych logowania", "manageYourSSHCredentials": "Zarządzaj swoimi danymi uwierzytelniającymi SSH bezpiecznie", "addCredential": "Dodaj poświadczenia", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "To bezpiecznie doda klucz publiczny do pliku hosta ~/.ssh/authorized_keys bez nadpisywania istniejących kluczy. Operacja jest odwracalna.", "chooseHostToDeploy": "Wybierz hosta do wdrożenia...", "deploying": "Wdrażanie...", - "copyDeployCommand": "Kopiuj polecenie wdrożenia", - "copyDeployCommandDescription": "Skopiuj polecenie powłoki, aby ręcznie dodać klucz publiczny do pliku authorized_keys hosta. Przydatne, gdy uwierzytelnianie hasła jest wyłączone.", - "deployCommandCopied": "Wdrożenie polecenia skopiowane do schowka", - "manualDeployInfo": "Wklej to polecenie w terminalu docelowego hosta (np. konsola Proxmox, IPMI lub fizyczny dostęp) aby dodać klucz SSH.", "name": "Nazwisko", "noHostsAvailable": "Brak dostępnych hostów", "noHostsMatchSearch": "Brak hostów pasujących do Twojego wyszukiwania", @@ -303,9 +298,7 @@ "createFolder": "Utwórz folder", "editFolder": "Edytuj folder", "editFolderDescription": "Dostosuj swój folder snippet", - "createFolderDescription": "Organizuj swoje fragmenty w folderach", - "confirmExecution": "Wykonać \"{{name}}\"?", - "confirmExecutionDesc": "Czy na pewno chcesz wykonać ten snippet?" + "createFolderDescription": "Organizuj swoje fragmenty w folderach" }, "commandHistory": { "title": "Historia", @@ -409,7 +402,6 @@ "required": "Wymagane", "optional": "Opcjonalnie", "connect": "Połącz", - "copied": "Skopiowano", "connecting": "Łączenie...", "creating": "Tworzenie...", "clear": "Wyczyść", @@ -502,7 +494,6 @@ "checking": "Sprawdzanie...", "checkingDatabase": "Sprawdzanie połączenia z bazą danych...", "checkingAuthentication": "Sprawdzanie uwierzytelniania...", - "backendReconnected": "Połączenie z serwerem przywrócone", "actions": "Akcje", "remove": "Usuń", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Host Manager", - "hosts": "Hosty", "sshHosts": "Hosty SSH", "noHosts": "Brak hostów SSH", "noHostsMessage": "Nie dodano jeszcze żadnych hostów SSH. Kliknij \"Dodaj hosta\", aby rozpocząć.", @@ -846,7 +836,7 @@ "failedToImportJson": "Nie udało się zaimportować pliku JSON", "connectionDetails": "Szczegóły połączenia", "organization": "Organizacja", - "ipAddress": "Adres IP lub nazwa hosta", + "ipAddress": "Adres IP", "port": "Port", "name": "Nazwisko", "username": "Nazwa użytkownika", @@ -925,8 +915,6 @@ "key": "Klucz", "credential": "Dane logowania", "none": "Brak", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Pyta Cię o autoryzację sieciową OPKSSH przy każdym połączeniu. Twój token uwierzytelniania potrwa 24 godziny, dopóki nie będzie musiał zostać przedłużony.", "selectCredential": "Wybierz dane logowania", "selectCredentialPlaceholder": "Wybierz poświadczenie...", "credentialRequired": "Dane logowania są wymagane podczas korzystania z uwierzytelniania logowania", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Interaktywne uwierzytelnianie klawiatury", "noneAuthDescription": "Ta metoda uwierzytelniania będzie używała interaktywnego uwierzytelniania klawiatury podczas łączenia z serwerem SSH.", "noneAuthDetails": "Interaktywne uwierzytelnianie klawiatury pozwala na zapytanie serwera o poświadczenia podczas połączenia. Jest to przydatne dla serwerów, które wymagają wieloetapowego uwierzytelniania lub jeśli nie chcesz zapisywać danych logowania lokalnie.", - "opksshAuthTitle": "Uwierzytelnianie OPKSSH", "forceKeyboardInteractive": "Wymuś Interaktywną Klawiaturę", "forceKeyboardInteractiveDesc": "Wymusza użycie interaktywnego uwierzytelniania klawiatury. Jest to czasami wymagane dla serwerów, które używają dwustopniowego uwierzytelniania (TOTP/2FA).", "overrideCredentialUsername": "Zastąp nazwę użytkownika", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Otwórz w przeglądarce", "warpgateContinue": "Zakończyłem uwierzytelnianie", "warpgateTimeout": "Limit czasu uwierzytelniania Warpgate. Proszę połączyć się ponownie.", - "opksshAuthRequired": "Wymagane uwierzytelnienie OPKSSH", - "opksshAuthDescription": "Zakończ uwierzytelnianie w przeglądarce, aby kontynuować. Ta sesja pozostanie ważna przez 24 godziny.", - "opksshAuthUrl": "Adres URL uwierzytelniania", - "opksshOpenBrowser": "Otwórz przeglądarkę do uwierzytelniania", - "opksshWaitingForAuth": "Oczekiwanie na uwierzytelnianie w przeglądarce...", - "opksshAuthenticating": "Przetwarzanie uwierzytelniania...", - "opksshTimeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie.", - "opksshAuthFailed": "Uwierzytelnianie nie powiodło się. Sprawdź swoje poświadczenia i spróbuj ponownie.", - "opksshConfigMissing": "Konfiguracja OPKSSH nie została znaleziona. Proszę utworzyć ~/.opk/config.yml z ustawieniami dostawcy OIDC. Zobacz dokumentacja: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Wprowadzić hasło?", "sudoPasswordPopupHint": "Naciśnij Enter, aby wstawić, Esc, aby odrzucić", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Automatycznie próbuje uwierzytelnić {{method}}...", "totpTimeout": "Limit czasu weryfikacji TOTP. Proszę ponownie połączyć.", "passwordTimeout": "Limit czasu weryfikacji hasła. Proszę połączyć się ponownie.", - "connectionRejected": "Połączenie odrzucone przez serwer. Sprawdź swoje uwierzytelnianie i konfigurację sieci.", - "hostKeyRejected": "Weryfikacja klucza hosta SSH odrzucona. Połączenie anulowane." + "connectionRejected": "Połączenie odrzucone przez serwer. Sprawdź swoje uwierzytelnianie i konfigurację sieci." }, "fileManager": { "title": "Menedżer plików", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Hasło zostało zresetowane", "passwordResetSuccessDesc": "Twoje hasło zostało pomyślnie zresetowane. Możesz teraz zalogować się przy użyciu nowego hasła." }, - "hostKey": { - "verifyNewHost": "Zweryfikuj klucz hosta SSH", - "keyChangedWarning": "Klucz hosta SSH został zmieniony", - "firstConnectionTitle": "Pierwsze połączenie z tym hostem", - "firstConnectionDescription": "Nie można ustalić autentyczności tego hosta. Sprawdź, czy odcisk palca odpowiada oczekiwaniom.", - "keyChangedDescription": "Klucz hosta dla tego serwera zmienił się od ostatniego połączenia. Może to wskazywać na problem bezpieczeństwa.", - "previousKey": "Poprzedni klucz", - "newFingerprint": "Nowy odcisk palca", - "fingerprint": "Odcisk palca", - "verifyInstructions": "Jeśli ufasz temu hostowi, kliknij przycisk Akceptuj, aby kontynuować i zapisać ten odcisk palca dla przyszłych połączeń.", - "securityWarning": "Ostrzeżenie bezpieczeństwa", - "acceptAndContinue": "Zaakceptuj i kontynuuj", - "acceptNewKey": "Akceptuj nowy klucz i kontynuuj" - }, "errors": { "notFound": "Strona nie znaleziona", "unauthorized": "Nieautoryzowany dostęp", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Pasek hosta i boczny", "snippetsSettings": "Fragmenty", - "confirmSnippetExecution": "Potwierdź wykonanie Snippet", - "confirmSnippetExecutionDesc": "Pokaż okno dialogowe potwierdzenia przed wykonaniem fragmentów", "updateSettings": "Aktualizacje", "disableUpdateCheck": "Wyłącz sprawdzanie aktualizacji", "disableUpdateCheckDesc": "Zatrzymaj sprawdzanie nowych wersji na tablicy startowej i menedżerskiej. Zmniejsza żądania sieci.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 lub przykład.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/pt_BR.json b/src/locales/translated/pt_BR.json index 5cd86843..006956ae 100644 --- a/src/locales/translated/pt_BR.json +++ b/src/locales/translated/pt_BR.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Credenciais", "credentialsViewer": "Visualizador de credenciais", "manageYourSSHCredentials": "Gerencie suas credenciais SSH de forma segura", "addCredential": "Adicionar Credencial", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Isto irá adicionar com segurança a chave pública ao arquivo ~/.ssh/authorized_keys sem sobrescrever as chaves existentes. A operação é reversível.", "chooseHostToDeploy": "Escolha um host para implantar em...", "deploying": "Implementando...", - "copyDeployCommand": "Copiar Comando de Implementação", - "copyDeployCommandDescription": "Copie um comando do shell para adicionar manualmente a chave pública ao arquivo authorized_keys de um host. Útil quando a autenticação de senha está desativada.", - "deployCommandCopied": "Implantar comando copiado para a área de transferência", - "manualDeployInfo": "Cole este comando no terminal do host alvo (por exemplo, console Proxmox, IPMI ou acesso físico) para adicionar a chave SSH.", "name": "Nome:", "noHostsAvailable": "Nenhum host disponível", "noHostsMatchSearch": "Nenhum host corresponde à sua pesquisa", @@ -303,9 +298,7 @@ "createFolder": "Criar pasta", "editFolder": "Editar Pasta", "editFolderDescription": "Personalize sua pasta de snippet", - "createFolderDescription": "Organize seus snippets em pastas", - "confirmExecution": "Executar \"{{name}}\"?", - "confirmExecutionDesc": "Tem certeza que deseja executar este snippet?" + "createFolderDescription": "Organize seus snippets em pastas" }, "commandHistory": { "title": "Histórico", @@ -409,7 +402,6 @@ "required": "Obrigatório", "optional": "Opcional", "connect": "Conectar", - "copied": "Copiado", "connecting": "Conectandochar@@0", "creating": "Criando...", "clear": "Limpar", @@ -502,7 +494,6 @@ "checking": "Verificandochar@@0", "checkingDatabase": "Verificando conexão com o banco de dados...", "checkingAuthentication": "Verificando autenticação...", - "backendReconnected": "Conexão do servidor restaurada", "actions": "Ações.", "remove": "Excluir", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gerenciador de Host", - "hosts": "Anfitriões", "sshHosts": "Hosts SSH", "noHosts": "Nenhum host SSH", "noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.", @@ -846,7 +836,7 @@ "failedToImportJson": "Falha ao importar arquivo JSON", "connectionDetails": "Detalhes da conexão", "organization": "Cliente", - "ipAddress": "Endereço IP ou Nome de Host", + "ipAddress": "Endereço IP", "port": "Porta", "name": "Nome:", "username": "Usuário:", @@ -925,8 +915,6 @@ "key": "Chave", "credential": "Credencial", "none": "Nenhuma", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Solicita a autenticação web do OPKSSH em cada conexão. Seu token de autenticação vai durar 24 horas até que ele deve ser renovado.", "selectCredential": "Selecionar Credencial", "selectCredentialPlaceholder": "Escolha uma credencial...", "credentialRequired": "A credencial é necessária para usar autenticação de credenciais", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autenticação interativa", "noneAuthDescription": "Este método de autenticação usará a autenticação interativa de teclado ao conectar ao servidor SSH.", "noneAuthDetails": "A autenticação interativa permite que o servidor lhe peça credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multi-fatores ou você não quer salvar as credenciais localmente.", - "opksshAuthTitle": "Autenticação OPKSSH", "forceKeyboardInteractive": "Forçar teclado interativo", "forceKeyboardInteractiveDesc": "Força o uso de autenticação interativa de teclado. Isso às vezes é necessário para servidores que usam a Autenticação de Dois Fatores (TOTP/2FA).", "overrideCredentialUsername": "Substituir o nome de usuário credencial", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Abrir no Navegador", "warpgateContinue": "Eu concluí a autenticação", "warpgateTimeout": "A autenticação do Warpgate expirou. Por favor reconecte-se.", - "opksshAuthRequired": "Autenticação OPKSSH necessária", - "opksshAuthDescription": "Conclua a autenticação no seu navegador para continuar. Essa sessão permanecerá válida por 24 horas.", - "opksshAuthUrl": "URL de autenticação", - "opksshOpenBrowser": "Abra o navegador para autenticar", - "opksshWaitingForAuth": "Aguardando autenticação no navegador...", - "opksshAuthenticating": "Processando autenticação...", - "opksshTimeout": "Tempo de autenticação esgotado. Por favor, tente novamente.", - "opksshAuthFailed": "Falha na autenticação. Por favor, verifique suas credenciais e tente novamente.", - "opksshConfigMissing": "Configuração OPKSSH não encontrada. Por favor, crie ~/.opk/config.yml com as configurações do provedor OIDC. Veja documentação: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Inserir senha?", "sudoPasswordPopupHint": "Pressione Enter para inserir, Esc para dispensar", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Experimentando automaticamente autenticação {{method}}...", "totpTimeout": "A verificação TOTP expirou. Por favor, reconecte-se.", "passwordTimeout": "Verificação de senha expirada. Por favor, reconecte-se.", - "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede.", - "hostKeyRejected": "Verificação de chave do host SSH rejeitada. Conexão cancelada." + "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede." }, "fileManager": { "title": "Gerenciador de Arquivos", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Senha redefinida com sucesso", "passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você agora pode entrar com sua nova senha." }, - "hostKey": { - "verifyNewHost": "Verificar chave do host SSH", - "keyChangedWarning": "Chave do host SSH alterada", - "firstConnectionTitle": "Primeira vez conectando-se a este host", - "firstConnectionDescription": "A autenticidade deste host não pode ser estabelecida. Verifique a impressão digital que você espera.", - "keyChangedDescription": "A chave do host para este servidor mudou desde a sua última conexão. Isso pode indicar um problema de segurança.", - "previousKey": "Chave anterior", - "newFingerprint": "Nova impressão digital", - "fingerprint": "Impressão digital", - "verifyInstructions": "Caso você confie neste host, clique em Aceitar para continuar e salvar esta impressão digital para conexões futuras.", - "securityWarning": "Aviso de segurança", - "acceptAndContinue": "Aceitar e Continuar", - "acceptNewKey": "Aceitar nova chave e continuar" - }, "errors": { "notFound": "Página não encontrada", "unauthorized": "Acesso não autorizado", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Host e Barra Lateral", "snippetsSettings": "Trechos", - "confirmSnippetExecution": "Confirmar execução de Snippet", - "confirmSnippetExecutionDesc": "Mostrar diálogo antes de executar snippets", "updateSettings": "Atualizações", "disableUpdateCheck": "Desativar verificação de atualizações", "disableUpdateCheckDesc": "Parar de verificar novas versões na inicialização e no painel. Reduz as solicitações de rede.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 ou exemplo.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/pt_PT.json b/src/locales/translated/pt_PT.json index 2fd9cbde..2da1d18b 100644 --- a/src/locales/translated/pt_PT.json +++ b/src/locales/translated/pt_PT.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Credenciais", "credentialsViewer": "Visualizador de credenciais", "manageYourSSHCredentials": "Gerencie suas credenciais SSH de forma segura", "addCredential": "Adicionar Credencial", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Isto irá adicionar com segurança a chave pública ao arquivo ~/.ssh/authorized_keys sem sobrescrever as chaves existentes. A operação é reversível.", "chooseHostToDeploy": "Escolha um host para implantar em...", "deploying": "Implementando...", - "copyDeployCommand": "Copiar Comando de Implementação", - "copyDeployCommandDescription": "Copie um comando do shell para adicionar manualmente a chave pública ao arquivo authorized_keys de um host. Útil quando a autenticação de senha está desativada.", - "deployCommandCopied": "Implantar comando copiado para a área de transferência", - "manualDeployInfo": "Cole este comando no terminal do host alvo (por exemplo, console Proxmox, IPMI ou acesso físico) para adicionar a chave SSH.", "name": "Nome:", "noHostsAvailable": "Nenhum host disponível", "noHostsMatchSearch": "Nenhum host corresponde à sua pesquisa", @@ -303,9 +298,7 @@ "createFolder": "Criar pasta", "editFolder": "Editar Pasta", "editFolderDescription": "Personalize sua pasta de snippet", - "createFolderDescription": "Organize seus snippets em pastas", - "confirmExecution": "Executar \"{{name}}\"?", - "confirmExecutionDesc": "Tem certeza que deseja executar este snippet?" + "createFolderDescription": "Organize seus snippets em pastas" }, "commandHistory": { "title": "Histórico", @@ -409,7 +402,6 @@ "required": "Obrigatório", "optional": "Opcional", "connect": "Conectar", - "copied": "Copiado", "connecting": "Conectandochar@@0", "creating": "Criando...", "clear": "Limpar", @@ -502,7 +494,6 @@ "checking": "Verificandochar@@0", "checkingDatabase": "Verificando conexão com o banco de dados...", "checkingAuthentication": "Verificando autenticação...", - "backendReconnected": "Conexão do servidor restaurada", "actions": "Ações.", "remove": "Excluir", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Gerenciador de Host", - "hosts": "Anfitriões", "sshHosts": "Hosts SSH", "noHosts": "Nenhum host SSH", "noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.", @@ -846,7 +836,7 @@ "failedToImportJson": "Falha ao importar arquivo JSON", "connectionDetails": "Detalhes da conexão", "organization": "Cliente", - "ipAddress": "Endereço IP ou Nome de Host", + "ipAddress": "Endereço IP", "port": "Porta", "name": "Nome:", "username": "Usuário:", @@ -925,8 +915,6 @@ "key": "Chave", "credential": "Credencial", "none": "Nenhuma", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Solicita a autenticação web do OPKSSH em cada conexão. Seu token de autenticação vai durar 24 horas até que ele deve ser renovado.", "selectCredential": "Selecionar Credencial", "selectCredentialPlaceholder": "Escolha uma credencial...", "credentialRequired": "A credencial é necessária para usar autenticação de credenciais", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autenticação interativa", "noneAuthDescription": "Este método de autenticação usará a autenticação interativa de teclado ao conectar ao servidor SSH.", "noneAuthDetails": "A autenticação interativa permite que o servidor lhe peça credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multi-fatores ou você não quer salvar as credenciais localmente.", - "opksshAuthTitle": "Autenticação OPKSSH", "forceKeyboardInteractive": "Forçar teclado interativo", "forceKeyboardInteractiveDesc": "Força o uso de autenticação interativa de teclado. Isso às vezes é necessário para servidores que usam a Autenticação de Dois Fatores (TOTP/2FA).", "overrideCredentialUsername": "Substituir o nome de usuário credencial", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Abrir no Navegador", "warpgateContinue": "Eu concluí a autenticação", "warpgateTimeout": "A autenticação do Warpgate expirou. Por favor reconecte-se.", - "opksshAuthRequired": "Autenticação OPKSSH necessária", - "opksshAuthDescription": "Conclua a autenticação no seu navegador para continuar. Essa sessão permanecerá válida por 24 horas.", - "opksshAuthUrl": "URL de autenticação", - "opksshOpenBrowser": "Abra o navegador para autenticar", - "opksshWaitingForAuth": "Aguardando autenticação no navegador...", - "opksshAuthenticating": "Processando autenticação...", - "opksshTimeout": "Tempo de autenticação esgotado. Por favor, tente novamente.", - "opksshAuthFailed": "Falha na autenticação. Por favor, verifique suas credenciais e tente novamente.", - "opksshConfigMissing": "Configuração OPKSSH não encontrada. Por favor, crie ~/.opk/config.yml com as configurações do provedor OIDC. Veja documentação: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Inserir senha?", "sudoPasswordPopupHint": "Pressione Enter para inserir, Esc para dispensar", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Experimentando automaticamente autenticação {{method}}...", "totpTimeout": "A verificação TOTP expirou. Por favor, reconecte-se.", "passwordTimeout": "Verificação de senha expirada. Por favor, reconecte-se.", - "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede.", - "hostKeyRejected": "Verificação de chave do host SSH rejeitada. Conexão cancelada." + "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede." }, "fileManager": { "title": "Gerenciador de Arquivos", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Senha redefinida com sucesso", "passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você agora pode entrar com sua nova senha." }, - "hostKey": { - "verifyNewHost": "Verificar chave do host SSH", - "keyChangedWarning": "Chave do host SSH alterada", - "firstConnectionTitle": "Primeira vez conectando-se a este host", - "firstConnectionDescription": "A autenticidade deste host não pode ser estabelecida. Verifique a impressão digital que você espera.", - "keyChangedDescription": "A chave do host para este servidor mudou desde a sua última conexão. Isso pode indicar um problema de segurança.", - "previousKey": "Chave anterior", - "newFingerprint": "Nova impressão digital", - "fingerprint": "Impressão digital", - "verifyInstructions": "Caso você confie neste host, clique em Aceitar para continuar e salvar esta impressão digital para conexões futuras.", - "securityWarning": "Aviso de segurança", - "acceptAndContinue": "Aceitar e Continuar", - "acceptNewKey": "Aceitar nova chave e continuar" - }, "errors": { "notFound": "Página não encontrada", "unauthorized": "Acesso não autorizado", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Host e Barra Lateral", "snippetsSettings": "Trechos", - "confirmSnippetExecution": "Confirmar execução de Snippet", - "confirmSnippetExecutionDesc": "Mostrar diálogo antes de executar snippets", "updateSettings": "Atualizações", "disableUpdateCheck": "Desativar verificação de atualizações", "disableUpdateCheckDesc": "Parar de verificar novas versões na inicialização e no painel. Reduz as solicitações de rede.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 ou exemplo.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/ro_RO.json b/src/locales/translated/ro_RO.json index e77266a9..e497068b 100644 --- a/src/locales/translated/ro_RO.json +++ b/src/locales/translated/ro_RO.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Acreditări", "credentialsViewer": "Vizualizare acreditări", "manageYourSSHCredentials": "Gestionează credențialele SSH în siguranță", "addCredential": "Adaugă acreditare", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Acest lucru va adăuga în siguranță cheia publică la fișierul ~/.ssh/Autorized_keys al gazdei țintă, fără a suprascrie cheile existente. Operația este reversibilă.", "chooseHostToDeploy": "Alege o gazdă pentru a o implementa...", "deploying": "Desfășurare...", - "copyDeployCommand": "Copiază comanda de lansare", - "copyDeployCommandDescription": "Copiați o comandă shell pentru a adăuga manual cheia publică la fișierul cu chei autorizate. Useful când autentificarea cu parolă este dezactivată.", - "deployCommandCopied": "Lansează comanda copiată în clipboard", - "manualDeployInfo": "Lipiți această comandă în terminalul gazdei țintă (de exemplu, consola Proxmox, IPMI, sau acces fizic) pentru a adăuga cheia SSH.", "name": "Nume", "noHostsAvailable": "Nu sunt gazde disponibile", "noHostsMatchSearch": "Nicio gazdă nu se potrivește cu căutarea ta", @@ -303,9 +298,7 @@ "createFolder": "Creare folder", "editFolder": "Editare dosar", "editFolderDescription": "Personalizați folderul dvs. snippet", - "createFolderDescription": "Organizați fragmentele în dosare", - "confirmExecution": "Execută \"{{name}}\"?", - "confirmExecutionDesc": "Ești sigur că vrei să execuți acest snippet?" + "createFolderDescription": "Organizați fragmentele în dosare" }, "commandHistory": { "title": "Istoric", @@ -409,7 +402,6 @@ "required": "Necesar", "optional": "Opţional", "connect": "Conectează-te", - "copied": "Copiat", "connecting": "Conectare...", "creating": "Creare...", "clear": "Curăță", @@ -502,7 +494,6 @@ "checking": "Verificare...", "checkingDatabase": "Verificare conexiune la baza de date...", "checkingAuthentication": "Verificare autentificare...", - "backendReconnected": "Conexiune server restaurată", "actions": "Acțiuni", "remove": "Elimină", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Manager Gazdă", - "hosts": "Gazde", "sshHosts": "Gazde SSH", "noHosts": "Fără gazde SSH", "noHostsMessage": "Nu ai adăugat încă nicio gazdă SSH. Apasă pe \"Adaugă Gazdă\" pentru a începe.", @@ -846,7 +836,7 @@ "failedToImportJson": "Nu s-a putut importa fișierul JSON", "connectionDetails": "Detalii conexiune", "organization": "Organizație", - "ipAddress": "Adresă IP sau nume gazdă", + "ipAddress": "Adresă IP", "port": "Portul", "name": "Nume", "username": "Nume", @@ -925,8 +915,6 @@ "key": "Cheie", "credential": "Acreditări", "none": "Niciunul", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Vă promite pentru OPKSSH web auth la fiecare conectare. Token-ul de autentificare va dura 24 de ore până va trebui reînnoit.", "selectCredential": "Selectare acreditare", "selectCredentialPlaceholder": "Alege o acreditare...", "credentialRequired": "Acreditarea este necesară atunci când se utilizează autentificarea acreditărilor", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Autentificare tastatură-interactivă", "noneAuthDescription": "Această metodă de autentificare va utiliza autentificarea interactivă a tastaturii la conectarea la serverul SSH.", "noneAuthDetails": "Autentificarea interactivă tastatură permite serverului să vă solicite acreditări în timpul conectării. Acest lucru este util pentru serverele care necesită autentificare multi-factor sau dacă nu doriţi să salvaţi acreditările la nivel local.", - "opksshAuthTitle": "Autentificare OPKSSH", "forceKeyboardInteractive": "Forțează tastatura- interactivă", "forceKeyboardInteractiveDesc": "Forțează utilizarea autentificării interactive a tastaturii. Acest lucru este uneori necesar pentru serverele care utilizează autentificarea în doi pași (TOTP/2FA).", "overrideCredentialUsername": "Suprascrie utilizatorul de acreditare", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Deschide în browser", "warpgateContinue": "Am finalizat autentificarea", "warpgateTimeout": "Autentificare la curbură expirată. Te rugăm să te reconectezi.", - "opksshAuthRequired": "Autentificare OPKSSH necesară", - "opksshAuthDescription": "Finalizați autentificarea în browser-ul dvs. pentru a continua. Această sesiune va rămâne valabilă 24 de ore.", - "opksshAuthUrl": "URL autentificare", - "opksshOpenBrowser": "Deschide browser-ul pentru autentificare", - "opksshWaitingForAuth": "Se așteaptă autentificarea în browser...", - "opksshAuthenticating": "Se procesează autentificarea...", - "opksshTimeout": "Autentificarea a expirat. Încercați din nou.", - "opksshAuthFailed": "Autentificare eșuată. Te rugăm să verifici acreditările și să încerci din nou.", - "opksshConfigMissing": "Configurația OPKSSH nu a fost găsită. Vă rugăm să creați ~/.opk/config.yml cu setările furnizorului OIDC. Vedeți documentația: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Introduceți parola?", "sudoPasswordPopupHint": "Apăsați Enter pentru inserare, Esc pentru a respinge", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Se încearcă autentificarea {{method}}...", "totpTimeout": "Verificare TOTP expirată. Te rugăm să te reconectezi.", "passwordTimeout": "Verificarea parolei a expirat. Vă rugăm să vă reconectați.", - "connectionRejected": "Conexiune respinsă de server. Verificați autentificarea și configurarea rețelei.", - "hostKeyRejected": "Verificarea cheii gazdă SSH a fost respinsă. Conexiune anulată." + "connectionRejected": "Conexiune respinsă de server. Verificați autentificarea și configurarea rețelei." }, "fileManager": { "title": "Manager fişiere", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Resetare parola reusita", "passwordResetSuccessDesc": "Parola ta a fost resetată cu succes. Acum te poți autentifica cu noua parolă." }, - "hostKey": { - "verifyNewHost": "Verificare cheie gazdă SSH", - "keyChangedWarning": "Cheie gazdă SSH schimbată", - "firstConnectionTitle": "Conectare pentru prima dată la această gazdă", - "firstConnectionDescription": "Autenticitatea acestui gazdă nu poate fi stabilită. Verificați ca amprenta să corespundă cu ceea ce așteptați.", - "keyChangedDescription": "Cheia gazdă a acestui server s-a schimbat de la ultima conexiune. Aceasta ar putea indica o problemă de securitate.", - "previousKey": "Cheie anterioară", - "newFingerprint": "Amprentă nouă", - "fingerprint": "Amprentă", - "verifyInstructions": "Dacă aveți încredere în această gazdă, faceți clic pe Accept pentru a continua și a salva amprenta pentru conexiunile viitoare.", - "securityWarning": "Avertizare de securitate", - "acceptAndContinue": "Acceptați și continuați", - "acceptNewKey": "Acceptați o nouă cheie și continuați" - }, "errors": { "notFound": "Pagina nu a fost găsită", "unauthorized": "Acces neautorizat", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Găzduire și bară laterală", "snippetsSettings": "Snippet-uri", - "confirmSnippetExecution": "Confirmare execuție snippet", - "confirmSnippetExecutionDesc": "Arată dialog de confirmare înainte de a executa fragmente", "updateSettings": "Actualizări", "disableUpdateCheck": "Dezactivează verificarea actualizării", "disableUpdateCheckDesc": "Opriți verificarea de noi versiuni la pornire și tablou de bord. Reduce cererile de rețea.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 sau exemplu.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/ru_RU.json b/src/locales/translated/ru_RU.json index 39ae8725..820f86c9 100644 --- a/src/locales/translated/ru_RU.json +++ b/src/locales/translated/ru_RU.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Полномочия", "credentialsViewer": "Просмотр учетных данных", "manageYourSSHCredentials": "Безопасное управление учётными данными SSH", "addCredential": "Добавить учетные данные", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Это безопасно добавит публичный ключ к файлу ~/.ssh/authorized_keys целевого хоста без перезаписи существующих ключей. Операция может быть отменена.", "chooseHostToDeploy": "Выберите хост для развертывания...", "deploying": "Развертывание...", - "copyDeployCommand": "Копировать команду развертывания", - "copyDeployCommandDescription": "Скопируйте команду оболочки, чтобы вручную добавить открытый ключ в файл authorized_keys узла. Полезно, если отключена аутентификация пароля.", - "deployCommandCopied": "Команда развертывания скопирована в буфер обмена", - "manualDeployInfo": "Вставьте эту команду в терминал целевого хоста (например, консоль Proxmox, IPMI или физический доступ), чтобы добавить ключ SSH.", "name": "Наименование", "noHostsAvailable": "Нет хостов", "noHostsMatchSearch": "Нет хостов, соответствующих вашему запросу", @@ -303,9 +298,7 @@ "createFolder": "Создать папку", "editFolder": "Изменить папку", "editFolderDescription": "Настройка папки сниппетов", - "createFolderDescription": "Организовать ваши сниппеты в папках", - "confirmExecution": "Выполнить \"{{name}}\"?", - "confirmExecutionDesc": "Вы уверены, что хотите выполнить этот сниппет?" + "createFolderDescription": "Организовать ваши сниппеты в папках" }, "commandHistory": { "title": "История", @@ -409,7 +402,6 @@ "required": "Требуется", "optional": "Опционально", "connect": "Подключиться", - "copied": "Скопировано", "connecting": "Подключение...", "creating": "Создание...", "clear": "Очистить", @@ -502,7 +494,6 @@ "checking": "Проверка...", "checkingDatabase": "Проверка подключения к базе данных...", "checkingAuthentication": "Проверка подлинности...", - "backendReconnected": "Соединение с сервером восстановлено", "actions": "Действия", "remove": "Удалить", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Менеджер хостов", - "hosts": "Узлы", "sshHosts": "SSH хосты", "noHosts": "Нет SSH хостов", "noHostsMessage": "Вы еще не добавили ни одного хоста SSH. Нажмите \"Добавить хост\", чтобы начать.", @@ -846,7 +836,7 @@ "failedToImportJson": "Не удалось импортировать JSON файл", "connectionDetails": "Детали подключения", "organization": "Организация", - "ipAddress": "IP-адрес или имя хоста", + "ipAddress": "IP-адрес", "port": "Порт", "name": "Наименование", "username": "Имя пользователя", @@ -925,8 +915,6 @@ "key": "Спецификация", "credential": "Полномочия", "none": "Нет", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Запрашивает OPKSSH авторизацию при каждом подключении. Ваш токен авторизации будет длиться 24 часа, пока он не будет продлен.", "selectCredential": "Выберите учетные данные", "selectCredentialPlaceholder": "Выберите учетные данные...", "credentialRequired": "Учетные данные требуются при использовании аутентификации", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Клавиатура - Интерактивная аутентификация", "noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию с клавиатурой при подключении к SSH серверу.", "noneAuthDetails": "Интерактивная аутентификация на клавиатуре позволяет серверу запрашивать учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторной аутентификации или если вы не хотите сохранять учетные данные локально.", - "opksshAuthTitle": "OPKSSH аутентификация", "forceKeyboardInteractive": "Интерактивная клавиатура", "forceKeyboardInteractiveDesc": "Принудительно использовать интерактивную аутентификацию с клавиатуры. Иногда требуется для серверов, использующих двухфакторную аутентификацию (TOTP/2FA).", "overrideCredentialUsername": "Переопределить имя пользователя", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Открыть в браузере", "warpgateContinue": "Я завершаю аутентификацию", "warpgateTimeout": "Тайм-аут аутентификации Warpgate. Пожалуйста, подключитесь снова.", - "opksshAuthRequired": "Требуется аутентификация OPKSSH", - "opksshAuthDescription": "Завершите аутентификацию в браузере, чтобы продолжить. Этот сеанс будет оставаться действительным в течение 24 часов.", - "opksshAuthUrl": "URL аутентификации", - "opksshOpenBrowser": "Аутентификация в браузере", - "opksshWaitingForAuth": "Ожидание аутентификации в браузере...", - "opksshAuthenticating": "Обработка аутентификации...", - "opksshTimeout": "Истекло время аутентификации. Попробуйте еще раз.", - "opksshAuthFailed": "Аутентификация не удалась. Проверьте учетные данные и повторите попытку.", - "opksshConfigMissing": "OPKSSH конфигурация не найдена. Пожалуйста, создайте ~/.opk/config.yml с вашими настройками OIDC провайдера. См. документацию: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Вставить пароль?", "sudoPasswordPopupHint": "Нажмите Enter для вставки, Esc для отключения", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Автоматически пытаться аутентификации {{method}}...", "totpTimeout": "Тайм-аут проверки TOTP. Пожалуйста, подключитесь снова.", "passwordTimeout": "Время ожидания подтверждения пароля истекло. Пожалуйста, подключитесь снова.", - "connectionRejected": "Соединение отклонено сервером. Проверьте вашу аутентификацию и конфигурацию сети.", - "hostKeyRejected": "Проверка SSH ключа хоста отклонена. Соединение отменено." + "connectionRejected": "Соединение отклонено сервером. Проверьте вашу аутентификацию и конфигурацию сети." }, "fileManager": { "title": "Файловый менеджер", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Пароль успешно сброшен", "passwordResetSuccessDesc": "Ваш пароль был успешно сброшен. Теперь вы можете войти с новым паролем." }, - "hostKey": { - "verifyNewHost": "Проверка SSH ключа хоста", - "keyChangedWarning": "SSH ключ хоста изменен", - "firstConnectionTitle": "Впервые подключение к этому узлу", - "firstConnectionDescription": "Логичность этого узла не может быть установлена. Проверьте отпечаток пальца так, что вы ожидаете.", - "keyChangedDescription": "Ключ хоста для этого сервера изменился с момента вашего последнего подключения. Это может указывать на проблему безопасности.", - "previousKey": "Предыдущий ключ", - "newFingerprint": "Новый отпечаток пальца", - "fingerprint": "Отпечаток пальца", - "verifyInstructions": "Если вы доверяете этому узлу, нажмите «Принять», чтобы продолжить, и сохраните отпечаток пальца для будущих подключений.", - "securityWarning": "Предупреждение о безопасности", - "acceptAndContinue": "Принять и продолжить", - "acceptNewKey": "Принять новый ключ и продолжить" - }, "errors": { "notFound": "Страница не найдена", "unauthorized": "Неавторизованный доступ", @@ -2063,8 +2026,6 @@ "terminalSettings": "Терминал", "hostSidebarSettings": "Узел и боковая панель", "snippetsSettings": "Сниппеты", - "confirmSnippetExecution": "Подтвердите исполнение сниппета", - "confirmSnippetExecutionDesc": "Показывать окно подтверждения перед выполнением сниппетов", "updateSettings": "Обновления", "disableUpdateCheck": "Отключить проверку обновлений", "disableUpdateCheckDesc": "Не проверять наличие новых версий при запуске и панели управления. Снижает сетевые запросы.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 или example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/sr_SP.json b/src/locales/translated/sr_SP.json index 5639ba4d..75fd20e2 100644 --- a/src/locales/translated/sr_SP.json +++ b/src/locales/translated/sr_SP.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Акредитиви", "credentialsViewer": "Прегледач акредитива", "manageYourSSHCredentials": "Безбедно управљајте својим SSH акредитивима", "addCredential": "Додај акредитив", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Ово ће безбедно додати јавни кључ у датотеку ~/.ssh/authorized_keys циљног хоста без преписивања постојећих кључева. Операција је реверзибилна.", "chooseHostToDeploy": "Изаберите хост за распоређивање...", "deploying": "Распоређивање...", - "copyDeployCommand": "Копирај команду за распоређивање", - "copyDeployCommandDescription": "Копирајте команду шелла да бисте ручно додали јавни кључ у датотеку authorized_keys хоста. Корисно када је аутентификација лозинком онемогућена.", - "deployCommandCopied": "Команда за распоређивање копирана у међуспремник", - "manualDeployInfo": "Налепите ову команду у терминал циљног хоста (нпр. Proxmox конзолу, IPMI или физички приступ) да бисте додали SSH кључ.", "name": "Име", "noHostsAvailable": "Нема доступних хостова", "noHostsMatchSearch": "Ниједан хост не одговара вашој претрази", @@ -303,9 +298,7 @@ "createFolder": "Направи фасциклу", "editFolder": "Уреди фасциклу", "editFolderDescription": "Прилагодите своју фасциклу са фрагментима кода", - "createFolderDescription": "Организујте своје фрагменте у фасцикле", - "confirmExecution": "Извршити „{{name}}“?", - "confirmExecutionDesc": "Да ли сте сигурни да желите да извршите овај исечак?" + "createFolderDescription": "Организујте своје фрагменте у фасцикле" }, "commandHistory": { "title": "Историја", @@ -409,7 +402,6 @@ "required": "Обавезно", "optional": "Опционо", "connect": "Повежи се", - "copied": "Копирано", "connecting": "Повезивање...", "creating": "Креирање...", "clear": "Јасно", @@ -502,7 +494,6 @@ "checking": "Провера...", "checkingDatabase": "Провера везе са базом података...", "checkingAuthentication": "Провера аутентификације...", - "backendReconnected": "Веза са сервером је враћена", "actions": "Акције", "remove": "Уклони", "revoke": "Поништи", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Менаџер домаћина", - "hosts": "Домаћини", "sshHosts": "SSH хостови", "noHosts": "Нема SSH хостова", "noHostsMessage": "Још нисте додали ниједан SSH хост. Кликните на „Додај хост“ да бисте започели.", @@ -846,7 +836,7 @@ "failedToImportJson": "Увоз JSON датотеке није успео", "connectionDetails": "Детаљи везе", "organization": "Организација", - "ipAddress": "IP адреса или име хоста", + "ipAddress": "ИП адреса", "port": "Лука", "name": "Име", "username": "Корисничко име", @@ -925,8 +915,6 @@ "key": "Кључ", "credential": "Акредитив", "none": "Ниједан", - "opkssh": "ОПКШ", - "opksshAuthDescription": "Пита вас за OPKSSH веб-аутентификацију при сваком повезивању. Ваш токен за аутентификацију ће трајати 24 сата док се не обнови.", "selectCredential": "Изаберите акредитив", "selectCredentialPlaceholder": "Изаберите акредитив...", "credentialRequired": "Акредитив је потребан када се користи аутентификација акредитива", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Интерактивна аутентификација помоћу тастатуре", "noneAuthDescription": "Ова метода аутентификације ће користити интерактивну аутентификацију помоћу тастатуре приликом повезивања са SSH сервером.", "noneAuthDetails": "Интерактивна аутентификација помоћу тастатуре омогућава серверу да вас пита за акредитиве током повезивања. Ово је корисно за сервере који захтевају вишефакторску аутентификацију или ако не желите да локално чувате акредитиве.", - "opksshAuthTitle": "OPKSSH аутентификација", "forceKeyboardInteractive": "Присилно интерактивно коришћење тастатуре", "forceKeyboardInteractiveDesc": "Приморава употребу интерактивне аутентификације помоћу тастатуре. Ово је понекад потребно за сервере који користе двофакторску аутентификацију (TOTP/2FA).", "overrideCredentialUsername": "Замени корисничко име за акредитиве", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Отвори у прегледачу", "warpgateContinue": "Завршио/ла сам аутентификацију", "warpgateTimeout": "Временско ограничење за аутентификацију Варпгејта је истекло. Молимо вас да се поново повежете.", - "opksshAuthRequired": "Потребна је OPKSSH аутентификација", - "opksshAuthDescription": "Завршите аутентификацију у прегледачу да бисте наставили. Ова сесија ће остати важећа 24 сата.", - "opksshAuthUrl": "URL адреса за аутентификацију", - "opksshOpenBrowser": "Отворите прегледач за аутентификацију", - "opksshWaitingForAuth": "Чекање аутентификације у прегледачу...", - "opksshAuthenticating": "Обрада аутентификације...", - "opksshTimeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново.", - "opksshAuthFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.", - "opksshConfigMissing": "Конфигурација OPKSSH није пронађена. Молимо вас да креирате ~/.opk/config.yml са подешавањима вашег OIDC провајдера. Погледајте документацију: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Унети лозинку?", "sudoPasswordPopupHint": "Притисните Enter да бисте унели, Esc да бисте одбацили", "sudoPasswordPopupConfirm": "Уметни", @@ -1359,8 +1337,7 @@ "automaticFallback": "Аутоматски покушај {{method}} аутентификације...", "totpTimeout": "Временско ограничење за верификацију TOTP-а је истекло. Молимо вас да се поново повежете.", "passwordTimeout": "Временско ограничење за верификацију лозинке је истекло. Молимо вас да се поново повежете.", - "connectionRejected": "Сервер је одбио везу. Проверите аутентификацију и конфигурацију мреже.", - "hostKeyRejected": "Верификација SSH кључа хоста је одбијена. Веза је отказана." + "connectionRejected": "Сервер је одбио везу. Проверите аутентификацију и конфигурацију мреже." }, "fileManager": { "title": "Менаџер датотека", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Ресетовање лозинке је успешно", "passwordResetSuccessDesc": "Ваша лозинка је успешно ресетована. Сада се можете пријавити са новом лозинком." }, - "hostKey": { - "verifyNewHost": "Проверите SSH кључ хоста", - "keyChangedWarning": "SSH кључ хоста је промењен", - "firstConnectionTitle": "Прво повезивање са овим хостом", - "firstConnectionDescription": "Аутентичност овог хоста не може бити утврђена. Проверите да ли отисак прста одговара очекивањима.", - "keyChangedDescription": "Кључ хоста за овај сервер се променио од ваше последње везе. Ово би могло да указује на безбедносни проблем.", - "previousKey": "Претходни кључ", - "newFingerprint": "Нови отисак прста", - "fingerprint": "Отисак прста", - "verifyInstructions": "Ако верујете овом хосту, кликните на „Прихвати“ да бисте наставили и сачували овај отисак прста за будуће везе.", - "securityWarning": "Безбедносно упозорење", - "acceptAndContinue": "Прихвати и настави", - "acceptNewKey": "Прихвати нови кључ и настави" - }, "errors": { "notFound": "Страница није пронађена", "unauthorized": "Неовлашћени приступ", @@ -2063,8 +2026,6 @@ "terminalSettings": "Терминал", "hostSidebarSettings": "Домаћин и бочна трака", "snippetsSettings": "Исечци", - "confirmSnippetExecution": "Потврди извршавање исечка", - "confirmSnippetExecutionDesc": "Прикажи дијалог за потврду пре извршавања исечака", "updateSettings": "Ажурирања", "disableUpdateCheck": "Онемогући проверу ажурирања", "disableUpdateCheckDesc": "Зауставите проверу нових верзија при покретању и на контролној табли. Смањује мрежне захтеве.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 или example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/sv_SE.json b/src/locales/translated/sv_SE.json index 81957e97..cdda960a 100644 --- a/src/locales/translated/sv_SE.json +++ b/src/locales/translated/sv_SE.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Användaruppgifter", "credentialsViewer": "Visning av användaruppgifter", "manageYourSSHCredentials": "Hantera dina SSH-uppgifter säkert", "addCredential": "Lägg till autentiseringsuppgifter", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Detta kommer säkert att lägga till den publika nyckeln till målvärdens ~/.ssh/authorized_keys fil utan att skriva över befintliga nycklar. Åtgärden är vändbar.", "chooseHostToDeploy": "Välj en värd att distribuera till...", "deploying": "Utrustar...", - "copyDeployCommand": "Kopiera distribuera kommando", - "copyDeployCommandDescription": "Kopiera ett skalkommando för att manuellt lägga till den offentliga nyckeln till en värds authorized_keys-fil. Användbar när lösenordsautentisering är inaktiverad.", - "deployCommandCopied": "Distributionskommandot kopierat till urklipp", - "manualDeployInfo": "Klistra in detta kommando i målvärdens terminal (t.ex. Proxmox-konsol, IPMI eller fysisk åtkomst) för att lägga till SSH-tangenten.", "name": "Namn", "noHostsAvailable": "Inga tillgängliga värdar", "noHostsMatchSearch": "Inga värdar matchar din sökning", @@ -303,9 +298,7 @@ "createFolder": "Skapa mapp", "editFolder": "Redigera mapp", "editFolderDescription": "Anpassa din textmodul-mapp", - "createFolderDescription": "Organisera dina textmoduler i mappar", - "confirmExecution": "Kör \"{{name}}\"?", - "confirmExecutionDesc": "Är du säker på att du vill köra denna snippet?" + "createFolderDescription": "Organisera dina textmoduler i mappar" }, "commandHistory": { "title": "Historik", @@ -409,7 +402,6 @@ "required": "Krävs", "optional": "Valfri", "connect": "Anslut", - "copied": "Kopierad", "connecting": "Ansluter...", "creating": "Skapar...", "clear": "Rensa", @@ -502,7 +494,6 @@ "checking": "Kontrollerar...", "checkingDatabase": "Kontrollerar databasanslutning...", "checkingAuthentication": "Kontrollerar autentisering...", - "backendReconnected": "Serveranslutning återställd", "actions": "Åtgärder", "remove": "Radera", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Värdhanterare", - "hosts": "Värdar", "sshHosts": "SSH värdar", "noHosts": "Inga SSH-värdar", "noHostsMessage": "Du har inte lagt till några SSH-värdar än. Klicka på \"Lägg till värd\" för att komma igång.", @@ -846,7 +836,7 @@ "failedToImportJson": "Det gick inte att importera JSON-fil", "connectionDetails": "Kontaktuppgifter", "organization": "Organisation", - "ipAddress": "IP-adress eller värdnamn", + "ipAddress": "IP-adress", "port": "Port", "name": "Namn", "username": "Användarnamn", @@ -925,8 +915,6 @@ "key": "Nyckel", "credential": "Uppgifter", "none": "Ingen", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Fråga dig för OPKSSH web-auth vid varje anslutning. Din auth token kommer att pågå i 24 timmar tills den måste förnyas.", "selectCredential": "Välj referens", "selectCredentialPlaceholder": "Välj en inloggning...", "credentialRequired": "Uppgifter krävs när autentisering används", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Tangentbord-Interaktiv autentisering", "noneAuthDescription": "Denna autentiseringsmetod kommer att använda tangentbord-interaktiv autentisering när du ansluter till SSH-servern.", "noneAuthDetails": "Tangentbord-interaktiv autentisering gör det möjligt för servern att fråga dig om autentiseringsuppgifter under anslutningen. Detta är användbart för servrar som kräver multi-faktor autentisering eller om du inte vill spara uppgifter lokalt.", - "opksshAuthTitle": "OPKSSH autentisering", "forceKeyboardInteractive": "Tvinga interaktiv tangentbord", "forceKeyboardInteractiveDesc": "Tvingar användning av tangentbords-interaktiv autentisering. Detta krävs ibland för servrar som använder tvåfaktorsautentisering (TOTP/2FA).", "overrideCredentialUsername": "Åsidosätt Användarnamn", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Öppna i webbläsare", "warpgateContinue": "Jag har slutfört autentisering", "warpgateTimeout": "Warpgate-autentiserings-timeout. Vänligen återanslut.", - "opksshAuthRequired": "OPKSSH-autentisering krävs", - "opksshAuthDescription": "Fullständig autentisering i din webbläsare för att fortsätta. Denna session kommer att förbli giltig i 24 timmar.", - "opksshAuthUrl": "URL för autentisering", - "opksshOpenBrowser": "Öppna webbläsaren för att autentisera", - "opksshWaitingForAuth": "Väntar på autentisering i webbläsaren...", - "opksshAuthenticating": "Bearbetar autentisering...", - "opksshTimeout": "Autentisering gick ut. Försök igen.", - "opksshAuthFailed": "Autentisering misslyckades. Kontrollera dina uppgifter och försök igen.", - "opksshConfigMissing": "OPKSSH-konfigurationen hittades inte. Vänligen skapa ~/.opk/config.yml med dina OIDC-leverantörsinställningar. Se dokumentation: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Infoga lösenord?", "sudoPasswordPopupHint": "Tryck Enter för att infoga Esc för att avfärda", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Försöker automatiskt {{method}} autentisering...", "totpTimeout": "TOTP-verifiering timeout. Vänligen återanslut.", "passwordTimeout": "Lösenordsverifiering är avklarad. Vänligen återanslut.", - "connectionRejected": "Anslutning avvisad av servern. Kontrollera din autentisering och nätverkskonfiguration.", - "hostKeyRejected": "Verifiering av SSH-värdnyckel avvisad. Anslutningen avbröts." + "connectionRejected": "Anslutning avvisad av servern. Kontrollera din autentisering och nätverkskonfiguration." }, "fileManager": { "title": "Filhanterare", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Lösenordsåterställning lyckades", "passwordResetSuccessDesc": "Ditt lösenord har återställts. Du kan nu logga in med ditt nya lösenord." }, - "hostKey": { - "verifyNewHost": "Verifiera SSH-värdnyckel", - "keyChangedWarning": "SSH Värdnyckel ändrad", - "firstConnectionTitle": "Första gången du ansluter till denna värd", - "firstConnectionDescription": "Autenticiteten hos denna värd kan inte fastställas. Kontrollera att fingeravtrycket matchar det du förväntar dig.", - "keyChangedDescription": "Värdnyckeln för denna server har ändrats sedan din senaste anslutning. Detta kan indikera ett säkerhetsproblem.", - "previousKey": "Föregående nyckel", - "newFingerprint": "Nytt fingeravtryck", - "fingerprint": "Fingeravtryck", - "verifyInstructions": "Om du litar på detta värd, klicka på Acceptera för att fortsätta och spara detta fingeravtryck för framtida anslutningar.", - "securityWarning": "Varning för säkerhet", - "acceptAndContinue": "Godkänn och fortsätt", - "acceptNewKey": "Acceptera ny nyckel och fortsätt" - }, "errors": { "notFound": "Sidan hittades inte", "unauthorized": "Obehörig åtkomst", @@ -2063,8 +2026,6 @@ "terminalSettings": "Terminal", "hostSidebarSettings": "Värd och sidofält", "snippetsSettings": "Textmoduler", - "confirmSnippetExecution": "Bekräfta utförande av textmodulen", - "confirmSnippetExecutionDesc": "Visa bekräftelsedialog innan du utför textmoduler", "updateSettings": "Uppdateringar", "disableUpdateCheck": "Inaktivera uppdateringskontroll", "disableUpdateCheckDesc": "Sluta leta efter nya versioner vid uppstart och instrumentbräda. Minskar nätverksförfrågningar.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 eller exempel.se", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/th_TH.json b/src/locales/translated/th_TH.json index 864da92f..32f09b89 100644 --- a/src/locales/translated/th_TH.json +++ b/src/locales/translated/th_TH.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "คุณสมบัติ", "credentialsViewer": "โปรแกรมดูข้อมูลประจำตัว", "manageYourSSHCredentials": "จัดการข้อมูลประจำตัว SSH ของคุณอย่างปลอดภัย", "addCredential": "เพิ่มข้อมูลรับรอง", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "การดำเนินการนี้จะเพิ่มคีย์สาธารณะลงในไฟล์ ~/.ssh/authorized_keys ของโฮสต์เป้าหมายอย่างปลอดภัยโดยไม่เขียนทับคีย์ที่มีอยู่ การดำเนินการนี้สามารถย้อนกลับได้", "chooseHostToDeploy": "เลือกโฮสต์ที่จะใช้ในการติดตั้ง...", "deploying": "กำลังติดตั้ง...", - "copyDeployCommand": "คัดลอกคำสั่งปรับใช้", - "copyDeployCommandDescription": "คัดลอกคำสั่งเชลล์เพื่อเพิ่มคีย์สาธารณะลงในไฟล์ authorized_keys ของโฮสต์ด้วยตนเอง มีประโยชน์เมื่อปิดใช้งานการตรวจสอบสิทธิ์ด้วยรหัสผ่าน", - "deployCommandCopied": "คัดลอกคำสั่งปรับใช้ไปยังคลิปบอร์ดแล้ว", - "manualDeployInfo": "วางคำสั่งนี้ลงในเทอร์มินัลของโฮสต์เป้าหมาย (เช่น คอนโซล Proxmox, IPMI หรือการเข้าถึงทางกายภาพ) เพื่อเพิ่มคีย์ SSH", "name": "ชื่อ", "noHostsAvailable": "ไม่มีโฮสต์ให้บริการ", "noHostsMatchSearch": "ไม่พบโฮสต์ใดตรงกับผลการค้นหาของคุณ", @@ -303,9 +298,7 @@ "createFolder": "สร้างโฟลเดอร์", "editFolder": "แก้ไขโฟลเดอร์", "editFolderDescription": "ปรับแต่งโฟลเดอร์โค้ดสั้นของคุณ", - "createFolderDescription": "จัดระเบียบข้อความย่อของคุณลงในโฟลเดอร์", - "confirmExecution": "ดำเนินการ \"{{name}}\"?", - "confirmExecutionDesc": "คุณแน่ใจหรือไม่ว่าต้องการเรียกใช้โค้ดส่วนนี้?" + "createFolderDescription": "จัดระเบียบข้อความย่อของคุณลงในโฟลเดอร์" }, "commandHistory": { "title": "ประวัติศาสตร์", @@ -409,7 +402,6 @@ "required": "ที่จำเป็น", "optional": "ไม่จำเป็น", "connect": "เชื่อมต่อ", - "copied": "คัดลอกแล้ว", "connecting": "กำลังเชื่อมต่อ...", "creating": "กำลังสร้าง...", "clear": "ชัดเจน", @@ -502,7 +494,6 @@ "checking": "กำลังตรวจสอบ...", "checkingDatabase": "กำลังตรวจสอบการเชื่อมต่อฐานข้อมูล...", "checkingAuthentication": "กำลังตรวจสอบการยืนยันตัวตน...", - "backendReconnected": "การเชื่อมต่อเซิร์ฟเวอร์กลับมาใช้งานได้แล้ว", "actions": "การกระทำ", "remove": "ลบ", "revoke": "ถอน", @@ -815,7 +806,6 @@ }, "hosts": { "title": "ผู้จัดการโฮสต์", - "hosts": "โฮสต์", "sshHosts": "โฮสต์ SSH", "noHosts": "ไม่มีโฮสต์ SSH", "noHostsMessage": "คุณยังไม่ได้เพิ่มโฮสต์ SSH ใดๆ คลิก \"เพิ่มโฮสต์\" เพื่อเริ่มต้น", @@ -846,7 +836,7 @@ "failedToImportJson": "ไม่สามารถนำเข้าไฟล์ JSON ได้", "connectionDetails": "รายละเอียดการเชื่อมต่อ", "organization": "องค์กร", - "ipAddress": "ที่อยู่ IP หรือชื่อโฮสต์", + "ipAddress": "ที่อยู่ IP", "port": "ท่าเรือ", "name": "ชื่อ", "username": "ชื่อผู้ใช้", @@ -925,8 +915,6 @@ "key": "สำคัญ", "credential": "ใบรับรอง", "none": "ไม่มี", - "opkssh": "โอพีเคเอสเอช", - "opksshAuthDescription": "ระบบจะขอให้คุณป้อนรหัสยืนยันตัวตน OPKSSH ทุกครั้งที่เชื่อมต่อ รหัสยืนยันตัวตนของคุณจะมีอายุ 24 ชั่วโมงก่อนที่จะต้องต่ออายุใหม่", "selectCredential": "เลือกข้อมูลประจำตัว", "selectCredentialPlaceholder": "เลือกข้อมูลประจำตัว...", "credentialRequired": "จำเป็นต้องระบุข้อมูลประจำตัวเมื่อใช้การตรวจสอบสิทธิ์ด้วยข้อมูลประจำตัว", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "การตรวจสอบสิทธิ์แบบโต้ตอบด้วยแป้นพิมพ์", "noneAuthDescription": "วิธีการยืนยันตัวตนนี้จะใช้การยืนยันตัวตนผ่านแป้นพิมพ์เมื่อเชื่อมต่อกับเซิร์ฟเวอร์ SSH", "noneAuthDetails": "การตรวจสอบสิทธิ์แบบโต้ตอบด้วยแป้นพิมพ์ช่วยให้เซิร์ฟเวอร์แจ้งให้คุณป้อนข้อมูลประจำตัวระหว่างการเชื่อมต่อ ซึ่งมีประโยชน์สำหรับเซิร์ฟเวอร์ที่ต้องการการตรวจสอบสิทธิ์แบบหลายปัจจัย หรือหากคุณไม่ต้องการบันทึกข้อมูลประจำตัวไว้ในเครื่อง", - "opksshAuthTitle": "การตรวจสอบสิทธิ์ OPKSSH", "forceKeyboardInteractive": "แป้นพิมพ์แบบอินเทอร์แอคทีฟของ Force", "forceKeyboardInteractiveDesc": "บังคับให้ใช้การตรวจสอบสิทธิ์แบบโต้ตอบผ่านแป้นพิมพ์ ซึ่งบางครั้งจำเป็นสำหรับเซิร์ฟเวอร์ที่ใช้การตรวจสอบสิทธิ์แบบสองปัจจัย (TOTP/2FA)", "overrideCredentialUsername": "แทนที่ชื่อผู้ใช้ข้อมูลประจำตัว", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "เปิดในเบราว์เซอร์", "warpgateContinue": "ฉันทำการยืนยันตัวตนเสร็จเรียบร้อยแล้ว", "warpgateTimeout": "การตรวจสอบสิทธิ์ผ่านวาร์ปหมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง", - "opksshAuthRequired": "ต้องใช้การตรวจสอบสิทธิ์ OPKSSH", - "opksshAuthDescription": "โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ เซสชันนี้จะมีอายุใช้งาน 24 ชั่วโมง", - "opksshAuthUrl": "URL สำหรับการตรวจสอบสิทธิ์", - "opksshOpenBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน", - "opksshWaitingForAuth": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...", - "opksshAuthenticating": "กำลังตรวจสอบสิทธิ์...", - "opksshTimeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง", - "opksshAuthFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง", - "opksshConfigMissing": "ไม่พบไฟล์การกำหนดค่า OPKSSH โปรดสร้างไฟล์ ~/.opk/config.yml ด้วยการตั้งค่าผู้ให้บริการ OIDC ของคุณ ดูเอกสารประกอบได้ที่: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "ใส่รหัสผ่าน?", "sudoPasswordPopupHint": "กด Enter เพื่อแทรก กด Esc เพื่อปิด", "sudoPasswordPopupConfirm": "แทรก", @@ -1359,8 +1337,7 @@ "automaticFallback": "กำลังลองยืนยันตัวตน {{method}} โดยอัตโนมัติ...", "totpTimeout": "การยืนยัน TOTP หมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง", "passwordTimeout": "การตรวจสอบรหัสผ่านหมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง", - "connectionRejected": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ โปรดตรวจสอบการยืนยันตัวตนและการกำหนดค่าเครือข่ายของคุณ", - "hostKeyRejected": "การตรวจสอบคีย์โฮสต์ SSH ถูกปฏิเสธ การเชื่อมต่อถูกยกเลิก" + "connectionRejected": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ โปรดตรวจสอบการยืนยันตัวตนและการกำหนดค่าเครือข่ายของคุณ" }, "fileManager": { "title": "ตัวจัดการไฟล์", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "การรีเซ็ตรหัสผ่านสำเร็จ", "passwordResetSuccessDesc": "รหัสผ่านของคุณถูกรีเซ็ตสำเร็จแล้ว คุณสามารถเข้าสู่ระบบด้วยรหัสผ่านใหม่ของคุณได้แล้ว" }, - "hostKey": { - "verifyNewHost": "ตรวจสอบคีย์โฮสต์ SSH", - "keyChangedWarning": "คีย์โฮสต์ SSH เปลี่ยนแล้ว", - "firstConnectionTitle": "นี่เป็นครั้งแรกที่เชื่อมต่อกับโฮสต์นี้", - "firstConnectionDescription": "ไม่สามารถตรวจสอบความถูกต้องของโฮสต์นี้ได้ โปรดตรวจสอบว่าลายนิ้วมือตรงกับที่คุณคาดหวังหรือไม่", - "keyChangedDescription": "รหัสโฮสต์ของเซิร์ฟเวอร์นี้มีการเปลี่ยนแปลงนับตั้งแต่การเชื่อมต่อครั้งล่าสุดของคุณ ซึ่งอาจบ่งชี้ถึงปัญหาด้านความปลอดภัย", - "previousKey": "คีย์ก่อนหน้า", - "newFingerprint": "ลายนิ้วมือใหม่", - "fingerprint": "ลายนิ้วมือ", - "verifyInstructions": "หากคุณเชื่อถือโฮสต์นี้ โปรดคลิก ยอมรับ เพื่อดำเนินการต่อและบันทึกข้อมูลนี้สำหรับการเชื่อมต่อในอนาคต", - "securityWarning": "คำเตือนด้านความปลอดภัย", - "acceptAndContinue": "ยอมรับและดำเนินการต่อ", - "acceptNewKey": "ยอมรับรหัสใหม่และดำเนินการต่อ" - }, "errors": { "notFound": "ไม่พบหน้าเว็บ", "unauthorized": "การเข้าถึงโดยไม่ได้รับอนุญาต", @@ -2063,8 +2026,6 @@ "terminalSettings": "เทอร์มินัล", "hostSidebarSettings": "โฮสต์และแถบด้านข้าง", "snippetsSettings": "เศษเสี้ยว", - "confirmSnippetExecution": "ยืนยันการเรียกใช้โค้ดสั้น", - "confirmSnippetExecutionDesc": "แสดงกล่องโต้ตอบยืนยันก่อนเรียกใช้โค้ดสั้นๆ", "updateSettings": "การอัปเดต", "disableUpdateCheck": "ปิดใช้งานการตรวจสอบการอัปเดต", "disableUpdateCheckDesc": "หยุดการตรวจสอบเวอร์ชันใหม่เมื่อเริ่มต้นระบบและบนแดชบอร์ด ช่วยลดจำนวนการร้องขอเครือข่าย", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 หรือ example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/tr_TR.json b/src/locales/translated/tr_TR.json index 78ef4b3a..a7ec671c 100644 --- a/src/locales/translated/tr_TR.json +++ b/src/locales/translated/tr_TR.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Kimlik Bilgileri", "credentialsViewer": "Kimlik Bilgileri Görüntüleyici", "manageYourSSHCredentials": "SSH kimlik bilgilerinizi güvenli bir şekilde yönetin.", "addCredential": "Kimlik Bilgisi Ekle", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Bu işlem, mevcut anahtarların üzerine yazmadan, hedef sunucunun ~/.ssh/authorized_keys dosyasına genel anahtarı güvenli bir şekilde ekleyecektir. İşlem geri alınabilir.", "chooseHostToDeploy": "Dağıtım yapılacak sunucuyu seçin...", "deploying": "Dağıtıma başlanıyor...", - "copyDeployCommand": "Dağıtım Komutunu Kopyala", - "copyDeployCommandDescription": "Bir sunucunun authorized_keys dosyasına genel anahtarı manuel olarak eklemek için bir shell komutunu kopyalayın. Parola kimlik doğrulamasının devre dışı bırakıldığı durumlarda kullanışlıdır.", - "deployCommandCopied": "Dağıtım komutu panoya kopyalandı.", - "manualDeployInfo": "SSH anahtarını eklemek için bu komutu hedef sunucunun terminaline (örneğin, Proxmox konsolu, IPMI veya fiziksel erişim) yapıştırın.", "name": "İsim", "noHostsAvailable": "Hiç ev sahibi mevcut değil.", "noHostsMatchSearch": "Arama kriterlerinize uyan ev sahibi bulunamadı.", @@ -303,9 +298,7 @@ "createFolder": "Klasör Oluştur", "editFolder": "Klasörü Düzenle", "editFolderDescription": "Kod parçacığı klasörünüzü özelleştirin.", - "createFolderDescription": "Kod parçacıklarınızı klasörler halinde düzenleyin.", - "confirmExecution": "\"{{name}}\"'yi yürüt?", - "confirmExecutionDesc": "Bu kod parçasını çalıştırmak istediğinizden emin misiniz?" + "createFolderDescription": "Kod parçacıklarınızı klasörler halinde düzenleyin." }, "commandHistory": { "title": "Tarih", @@ -409,7 +402,6 @@ "required": "Gerekli", "optional": "İsteğe bağlı", "connect": "Bağlamak", - "copied": "Kopyalandı", "connecting": "Bağlanıyor...", "creating": "Oluşturuluyor...", "clear": "Temizlemek", @@ -502,7 +494,6 @@ "checking": "Kontrol ediliyor...", "checkingDatabase": "Veritabanı bağlantısı kontrol ediliyor...", "checkingAuthentication": "Kimlik doğrulama kontrol ediliyor...", - "backendReconnected": "Sunucu bağlantısı yeniden kuruldu", "actions": "Eylemler", "remove": "Kaldırmak", "revoke": "Geri çekmek", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Sunucu Yöneticisi", - "hosts": "Ev sahipleri", "sshHosts": "SSH Sunucuları", "noHosts": "SSH Sunucusu Yok", "noHostsMessage": "Henüz hiçbir SSH sunucusu eklemediniz. Başlamak için \"Sunucu Ekle\"ye tıklayın.", @@ -846,7 +836,7 @@ "failedToImportJson": "JSON dosyasını içe aktarma başarısız oldu.", "connectionDetails": "Bağlantı Ayrıntıları", "organization": "Organizasyon", - "ipAddress": "IP Adresi veya Ana Bilgisayar Adı", + "ipAddress": "IP Adresi", "port": "Liman", "name": "İsim", "username": "Kullanıcı adı", @@ -925,8 +915,6 @@ "key": "Anahtar", "credential": "Kimlik belgesi", "none": "Hiçbiri", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Her bağlantıda OPKSSH web kimlik doğrulaması için sizden onay ister. Kimlik doğrulama belirteciniz 24 saat boyunca geçerli olacak ve daha sonra yenilenmesi gerekecektir.", "selectCredential": "Kimlik Bilgisi Seçin", "selectCredentialPlaceholder": "Bir yeterlilik belgesi seçin...", "credentialRequired": "Kimlik doğrulama kullanılırken kimlik bilgisi gereklidir.", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Klavye Etkileşimli Kimlik Doğrulama", "noneAuthDescription": "Bu kimlik doğrulama yöntemi, SSH sunucusuna bağlanırken klavye etkileşimli kimlik doğrulamasını kullanacaktır.", "noneAuthDetails": "Klavyeyle etkileşimli kimlik doğrulama, sunucunun bağlantı sırasında sizden kimlik bilgilerini istemesine olanak tanır. Bu, çok faktörlü kimlik doğrulama gerektiren sunucular için veya kimlik bilgilerini yerel olarak kaydetmek istemiyorsanız kullanışlıdır.", - "opksshAuthTitle": "OPKSSH Kimlik Doğrulaması", "forceKeyboardInteractive": "Klavyeyle Etkileşimi Zorla", "forceKeyboardInteractiveDesc": "Klavyeyle etkileşimli kimlik doğrulama kullanımını zorunlu kılar. Bu, bazen İki Faktörlü Kimlik Doğrulama (TOTP/2FA) kullanan sunucular için gereklidir.", "overrideCredentialUsername": "Kimlik Bilgisi Kullanıcı Adını Geçersiz Kıl", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Tarayıcıda aç", "warpgateContinue": "Kimlik doğrulama işlemini tamamladım.", "warpgateTimeout": "Warpgate kimlik doğrulama zaman aşımı. Lütfen yeniden bağlanın.", - "opksshAuthRequired": "OPKSSH Kimlik Doğrulaması Gerekli", - "opksshAuthDescription": "Devam etmek için tarayıcınızda kimlik doğrulama işlemini tamamlayın. Bu oturum 24 saat boyunca geçerli olacaktır.", - "opksshAuthUrl": "Kimlik Doğrulama URL'si", - "opksshOpenBrowser": "Kimlik doğrulaması için tarayıcıyı açın.", - "opksshWaitingForAuth": "Tarayıcıda kimlik doğrulaması bekleniyor...", - "opksshAuthenticating": "Kimlik doğrulama işlemi gerçekleştiriliyor...", - "opksshTimeout": "Kimlik doğrulama işlemi zaman aşımına uğradı. Lütfen tekrar deneyin.", - "opksshAuthFailed": "Kimlik doğrulama başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edin ve tekrar deneyin.", - "opksshConfigMissing": "OPKSSH yapılandırması bulunamadı. Lütfen OIDC sağlayıcı ayarlarınızla birlikte ~/.opk/config.yml dosyasını oluşturun. Belgelere bakın: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Şifrenizi girin?", "sudoPasswordPopupHint": "Eklemek için Enter'a, silmek için Esc'ye basın.", "sudoPasswordPopupConfirm": "Sokmak", @@ -1359,8 +1337,7 @@ "automaticFallback": "Otomatik olarak {{method}} kimlik doğrulaması deneniyor...", "totpTimeout": "TOTP doğrulama zaman aşımına uğradı. Lütfen yeniden bağlanın.", "passwordTimeout": "Parola doğrulama zaman aşımına uğradı. Lütfen yeniden bağlanın.", - "connectionRejected": "Sunucu bağlantıyı reddetti. Lütfen kimlik doğrulama ve ağ yapılandırmanızı kontrol edin.", - "hostKeyRejected": "SSH sunucu anahtarı doğrulama işlemi reddedildi. Bağlantı iptal edildi." + "connectionRejected": "Sunucu bağlantıyı reddetti. Lütfen kimlik doğrulama ve ağ yapılandırmanızı kontrol edin." }, "fileManager": { "title": "Dosya Yöneticisi", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Şifre Sıfırlama Başarılı", "passwordResetSuccessDesc": "Parolanız başarıyla sıfırlandı. Artık yeni parolanızla giriş yapabilirsiniz." }, - "hostKey": { - "verifyNewHost": "SSH Ana Bilgisayar Anahtarını Doğrulayın", - "keyChangedWarning": "SSH Ana Bilgisayar Anahtarı Değiştirildi", - "firstConnectionTitle": "Bu sunucuya ilk kez bağlanıyorum.", - "firstConnectionDescription": "Bu sunucunun kimliği doğrulanamamıştır. Parmak izinin beklediğinizle eşleştiğini doğrulayın.", - "keyChangedDescription": "Bu sunucunun anahtar kodu son bağlantınızdan bu yana değişti. Bu bir güvenlik sorununa işaret edebilir.", - "previousKey": "Önceki Anahtar", - "newFingerprint": "Yeni Parmak İzi", - "fingerprint": "Parmak izi", - "verifyInstructions": "Bu sunucuya güveniyorsanız, devam etmek ve bu parmak izini gelecekteki bağlantılar için kaydetmek üzere Kabul Et'e tıklayın.", - "securityWarning": "Güvenlik Uyarısı", - "acceptAndContinue": "Kabul Et ve Devam Et", - "acceptNewKey": "Yeni Anahtarı Kabul Et ve Devam Et" - }, "errors": { "notFound": "Sayfa bulunamadı.", "unauthorized": "Yetkisiz erişim", @@ -2063,8 +2026,6 @@ "terminalSettings": "terminal", "hostSidebarSettings": "Sunucu ve Yan Menü", "snippetsSettings": "Kısa bölümler", - "confirmSnippetExecution": "Kod Parçasının Yürütülmesini Onayla", - "confirmSnippetExecutionDesc": "Kod parçacıklarını çalıştırmadan önce onay iletişim kutusunu göster.", "updateSettings": "Güncellemeler", "disableUpdateCheck": "Güncelleme Kontrolünü Devre Dışı Bırak", "disableUpdateCheckDesc": "Başlangıçta ve kontrol panelinde yeni sürümleri kontrol etmeyi durdurun. Ağ isteklerini azaltır.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 veya example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/uk_UA.json b/src/locales/translated/uk_UA.json index e3c2405c..f1a6f8cb 100644 --- a/src/locales/translated/uk_UA.json +++ b/src/locales/translated/uk_UA.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Дані доступу", "credentialsViewer": "Переглядач облікових даних", "manageYourSSHCredentials": "Безпечно керувати вашими обліковими даними SSH", "addCredential": "Додати", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Це безпечно додасть відкритий ключ до цільового хоста ~/.ssh/authorized_keys файл без перезапису існуючих ключів. Операція незворотна.", "chooseHostToDeploy": "Виберіть хост для розгортання...", "deploying": "Розгортання...", - "copyDeployCommand": "Скопіювати Команду Розгортання", - "copyDeployCommandDescription": "Скопіювати команду оболонки в ручне додавання відкритого ключа в файл authorized_keys. Корисно, коли автентифікація пароля вимкнена.", - "deployCommandCopied": "Команда розгортання скопійована до буферу обміну", - "manualDeployInfo": "Вставити цю команду в терміналі цільового хоста (наприклад, консоль Proxmox, IPMI або фізичний доступ), щоб додати SSH ключ.", "name": "Ім'я", "noHostsAvailable": "Немає доступних хостів", "noHostsMatchSearch": "Немає хостів, які відповідають вашому пошуку", @@ -303,9 +298,7 @@ "createFolder": "Створити теку", "editFolder": "Змінити теку", "editFolderDescription": "Налаштуйте вашу теку зі сніпетом", - "createFolderDescription": "Організуйте ваші сніпети у папки", - "confirmExecution": "Виконати{{name}}\"?", - "confirmExecutionDesc": "Ви впевнені, що хочете зробити цей сніпет?" + "createFolderDescription": "Організуйте ваші сніпети у папки" }, "commandHistory": { "title": "Історія", @@ -409,7 +402,6 @@ "required": "Обов'язково", "optional": "За бажанням", "connect": "Підключитися", - "copied": "Скопійовано", "connecting": "З’єднання...", "creating": "Створюю...", "clear": "Очистити", @@ -502,7 +494,6 @@ "checking": "Перевірка...", "checkingDatabase": "Перевірка підключення до бази даних...", "checkingAuthentication": "Перевірка автентифікації...", - "backendReconnected": "З'єднання з сервером відновлено", "actions": "Дії", "remove": "Видалити", "revoke": "Revoke", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Менеджер хостів", - "hosts": "Хости", "sshHosts": "SSH хости", "noHosts": "Немає SSH хостів", "noHostsMessage": "Ви ще не додали жодного SSH хоста. Натисніть \"Додати хоста\".", @@ -846,7 +836,7 @@ "failedToImportJson": "Не вдалося імпортувати файл JSON", "connectionDetails": "Відомості про підключення", "organization": "Організація", - "ipAddress": "IP-адреса або ім'я хоста", + "ipAddress": "IP-адреса", "port": "Порт", "name": "Ім'я", "username": "Ім'я користувача", @@ -925,8 +915,6 @@ "key": "Ключ", "credential": "Облікові дані", "none": "Без ефекту", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Ви заохочуєте вас до ОПК-авторизацію при кожному підключенні. Токен автентифікації буде тривати 24 години, поки він буде буде поновлений.", "selectCredential": "Оберіть облікові дані", "selectCredentialPlaceholder": "Виберіть облікові дані...", "credentialRequired": "Обліковий запис потрібен під час використання аутентифікації облікових даних", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Клавіатура-інтерактивна автентифікація", "noneAuthDescription": "Цей метод автентифікації використовуватиме клавіатурну інтерактивну автентифікацію при підключенні до SSH сервера.", "noneAuthDetails": "Увімкнута інтерактивна клавіатура дозволяє серверу підводити Ваші дані під час підключення. Це корисно для серверів, які потребують багатофакторної аутентифікації або якщо ви не хочете зберегти облікові дані локально.", - "opksshAuthTitle": "Аутентифікація OPKSSH", "forceKeyboardInteractive": "Примусова інтерактивна клавіатура", "forceKeyboardInteractiveDesc": "Вказує використання клавіатури для автентифікації. Іноді це потрібно для серверів, які використовують двофакторну автентифікацію (TOTP/2FA).", "overrideCredentialUsername": "Перевизначити ім'я користувача", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Відкрити у браузері", "warpgateContinue": "Я вже завершив автентифікацію", "warpgateTimeout": "Тайм-аут аутентифікації warpgate будь ласка перепід'єднайтеся.", - "opksshAuthRequired": "Необхідна автентифікація OPKSSH", - "opksshAuthDescription": "Завершіть автентифікацію в вашому браузері, щоб продовжити. Ця сесія залишиться дійсною протягом 24 годин.", - "opksshAuthUrl": "URL автентифікації", - "opksshOpenBrowser": "Відкрити браузер для аутентифікації", - "opksshWaitingForAuth": "Очікування аутентифікації в браузері...", - "opksshAuthenticating": "Обробка автентифікації...", - "opksshTimeout": "Час аутентифікації минув. Будь ласка, спробуйте ще раз.", - "opksshAuthFailed": "Помилка авторизації. Будь ласка, перевірте свої облікові дані та спробуйте ще раз.", - "opksshConfigMissing": "Конфігурацію OPKSSH не знайдено. Будь ласка, створіть ~/.opk/config.yml з параметрами постачальника OIDC. Див. документацію: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Введіть пароль?", "sudoPasswordPopupHint": "Натисніть Enter, щоб вставити, Esc для відхилення", "sudoPasswordPopupConfirm": "Insert", @@ -1359,8 +1337,7 @@ "automaticFallback": "Автоматично пробує автентифікацію {{method}}...", "totpTimeout": "Таймаут перевірки TOTP. Будь ласка, під'єднайтеся.", "passwordTimeout": "Час очікування підтвердження пароля. Будь ласка, під'єднайтеся.", - "connectionRejected": "З'єднання відхилено сервером. Будь ласка, перевірте налаштування аутентифікації та мережі.", - "hostKeyRejected": "SSH-ключ хоста відхилено. Підключення скасовано." + "connectionRejected": "З'єднання відхилено сервером. Будь ласка, перевірте налаштування аутентифікації та мережі." }, "fileManager": { "title": "Файловий менеджер", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Пароль успішно скинуто", "passwordResetSuccessDesc": "Ваш пароль успішно скинуто. Тепер ви можете увійти в систему, використовуючи новий пароль." }, - "hostKey": { - "verifyNewHost": "Перевірити SSH ключ хоста", - "keyChangedWarning": "SSH ключ хоста змінено", - "firstConnectionTitle": "вперше при підключенні до цього хосту", - "firstConnectionDescription": "Неможливо встановити автентифікацію цього хоста. Перевірте, чи збігається ваш запит.", - "keyChangedDescription": "Ключ хоста для цього сервера було змінено після вашого останнього підключення. Це може означати проблему безпеки.", - "previousKey": "Попередній ключ", - "newFingerprint": "Новий відбиток пальця", - "fingerprint": "Відбиток пальця", - "verifyInstructions": "Щоб продовжити і зберегти цей відбиток для майбутніх з'єднань, натисніть кнопку \"Прийняти\".", - "securityWarning": "Попередження системи безпеки", - "acceptAndContinue": "Прийняти та продовжити", - "acceptNewKey": "Прийняти новий ключ і продовжити" - }, "errors": { "notFound": "Сторінка не знайдена", "unauthorized": "Неавторизований доступ", @@ -2063,8 +2026,6 @@ "terminalSettings": "Термінал", "hostSidebarSettings": "Хост і бічна панель", "snippetsSettings": "Сніпети", - "confirmSnippetExecution": "Підтвердити виконання сніпета", - "confirmSnippetExecutionDesc": "Показати діалогове вікно підтвердження перед виконанням сніпетів", "updateSettings": "Оновлення", "disableUpdateCheck": "Вимкнути перевірку оновлень", "disableUpdateCheckDesc": "Зупинити перевірку для нових версій при запуску і панелі керування. Зменшує запити на мережі.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 або приклад.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/vi_VN.json b/src/locales/translated/vi_VN.json index 7680073f..364980c6 100644 --- a/src/locales/translated/vi_VN.json +++ b/src/locales/translated/vi_VN.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "Thông tin xác thực", "credentialsViewer": "Trình xem thông tin đăng nhập", "manageYourSSHCredentials": "Quản lý thông tin đăng nhập SSH của bạn một cách an toàn", "addCredential": "Thêm thông tin đăng nhập", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "Thao tác này sẽ thêm khóa công khai một cách an toàn vào tệp ~/.ssh/authorized_keys của máy chủ đích mà không ghi đè lên các khóa hiện có. Thao tác này có thể đảo ngược.", "chooseHostToDeploy": "Chọn máy chủ để triển khai...", "deploying": "Đang triển khai...", - "copyDeployCommand": "Sao chép lệnh triển khai", - "copyDeployCommandDescription": "Sao chép lệnh shell để thêm thủ công khóa công khai vào tệp authorized_keys của máy chủ. Hữu ích khi xác thực bằng mật khẩu bị vô hiệu hóa.", - "deployCommandCopied": "Lệnh triển khai đã được sao chép vào clipboard", - "manualDeployInfo": "Dán lệnh này vào cửa sổ terminal của máy chủ đích (ví dụ: console Proxmox, IPMI hoặc truy cập vật lý) để thêm khóa SSH.", "name": "Tên", "noHostsAvailable": "Hiện không có máy chủ nào khả dụng", "noHostsMatchSearch": "Không có máy chủ nào phù hợp với tìm kiếm của bạn", @@ -303,9 +298,7 @@ "createFolder": "Tạo thư mục", "editFolder": "Chỉnh sửa thư mục", "editFolderDescription": "Tùy chỉnh thư mục đoạn mã của bạn", - "createFolderDescription": "Sắp xếp các đoạn văn bản của bạn vào các thư mục.", - "confirmExecution": "Thực thi \"{{name}}\"?", - "confirmExecutionDesc": "Bạn có chắc chắn muốn thực thi đoạn mã này không?" + "createFolderDescription": "Sắp xếp các đoạn văn bản của bạn vào các thư mục." }, "commandHistory": { "title": "Lịch sử", @@ -409,7 +402,6 @@ "required": "Yêu cầu", "optional": "Không bắt buộc", "connect": "Kết nối", - "copied": "Đã sao chép", "connecting": "Đang kết nối...", "creating": "Đang tạo...", "clear": "Thông thoáng", @@ -502,7 +494,6 @@ "checking": "Đang kiểm tra...", "checkingDatabase": "Kiểm tra kết nối cơ sở dữ liệu...", "checkingAuthentication": "Đang kiểm tra xác thực...", - "backendReconnected": "Kết nối máy chủ đã được khôi phục.", "actions": "Hành động", "remove": "Di dời", "revoke": "Thu hồi", @@ -815,7 +806,6 @@ }, "hosts": { "title": "Quản lý máy chủ", - "hosts": "Người dẫn chương trình", "sshHosts": "Máy chủ SSH", "noHosts": "Không có máy chủ SSH", "noHostsMessage": "Bạn chưa thêm bất kỳ máy chủ SSH nào. Nhấp vào \"Thêm máy chủ\" để bắt đầu.", @@ -846,7 +836,7 @@ "failedToImportJson": "Không thể nhập tệp JSON.", "connectionDetails": "Chi tiết kết nối", "organization": "Tổ chức", - "ipAddress": "Địa chỉ IP hoặc tên máy chủ", + "ipAddress": "Địa chỉ IP", "port": "Cảng", "name": "Tên", "username": "Tên người dùng", @@ -925,8 +915,6 @@ "key": "Chìa khóa", "credential": "Chứng chỉ", "none": "Không có", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Hệ thống sẽ yêu cầu bạn xác thực web bằng OPKSSH mỗi khi kết nối. Mã xác thực của bạn sẽ có hiệu lực trong 24 giờ trước khi cần được gia hạn.", "selectCredential": "Chọn thông tin xác thực", "selectCredentialPlaceholder": "Chọn một loại chứng chỉ...", "credentialRequired": "Cần có thông tin đăng nhập khi sử dụng phương thức xác thực bằng thông tin đăng nhập.", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "Xác thực tương tác bàn phím", "noneAuthDescription": "Phương thức xác thực này sẽ sử dụng xác thực tương tác bàn phím khi kết nối với máy chủ SSH.", "noneAuthDetails": "Xác thực tương tác bàn phím cho phép máy chủ yêu cầu bạn nhập thông tin đăng nhập trong quá trình kết nối. Điều này hữu ích cho các máy chủ yêu cầu xác thực đa yếu tố hoặc nếu bạn không muốn lưu thông tin đăng nhập cục bộ.", - "opksshAuthTitle": "Xác thực OPKSSH", "forceKeyboardInteractive": "Buộc tương tác bàn phím", "forceKeyboardInteractiveDesc": "Buộc sử dụng xác thực tương tác bàn phím. Điều này đôi khi cần thiết đối với các máy chủ sử dụng xác thực hai yếu tố (TOTP/2FA).", "overrideCredentialUsername": "Ghi đè tên người dùng xác thực", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "Mở trong trình duyệt", "warpgateContinue": "Tôi đã hoàn tất xác thực.", "warpgateTimeout": "Lỗi xác thực Warpgate đã hết thời gian chờ. Vui lòng kết nối lại.", - "opksshAuthRequired": "Yêu cầu xác thực OPKSSH", - "opksshAuthDescription": "Hoàn tất xác thực trên trình duyệt của bạn để tiếp tục. Phiên này sẽ có hiệu lực trong 24 giờ.", - "opksshAuthUrl": "URL xác thực", - "opksshOpenBrowser": "Mở trình duyệt để xác thực.", - "opksshWaitingForAuth": "Đang chờ xác thực trên trình duyệt...", - "opksshAuthenticating": "Đang xử lý xác thực...", - "opksshTimeout": "Quá trình xác thực đã hết hạn. Vui lòng thử lại.", - "opksshAuthFailed": "Xác thực không thành công. Vui lòng kiểm tra lại thông tin đăng nhập của bạn và thử lại.", - "opksshConfigMissing": "Không tìm thấy cấu hình OPKSSH. Vui lòng tạo tệp ~/.opk/config.yml với các cài đặt nhà cung cấp OIDC của bạn. Xem tài liệu: https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "Nhập mật khẩu?", "sudoPasswordPopupHint": "Nhấn Enter để chèn, Esc để xóa", "sudoPasswordPopupConfirm": "Chèn", @@ -1359,8 +1337,7 @@ "automaticFallback": "Đang tự động thử xác thực {{method}} ...", "totpTimeout": "Đã hết thời gian xác thực TOTP. Vui lòng kết nối lại.", "passwordTimeout": "Đã hết thời gian xác thực mật khẩu. Vui lòng kết nối lại.", - "connectionRejected": "Kết nối bị máy chủ từ chối. Vui lòng kiểm tra cấu hình xác thực và mạng của bạn.", - "hostKeyRejected": "Xác thực khóa máy chủ SSH bị từ chối. Kết nối bị hủy." + "connectionRejected": "Kết nối bị máy chủ từ chối. Vui lòng kiểm tra cấu hình xác thực và mạng của bạn." }, "fileManager": { "title": "Trình quản lý tập tin", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "Đặt lại mật khẩu thành công", "passwordResetSuccessDesc": "Mật khẩu của bạn đã được đặt lại thành công. Giờ bạn có thể đăng nhập bằng mật khẩu mới." }, - "hostKey": { - "verifyNewHost": "Xác minh khóa máy chủ SSH", - "keyChangedWarning": "Khóa máy chủ SSH đã thay đổi", - "firstConnectionTitle": "Lần đầu tiên kết nối với máy chủ này.", - "firstConnectionDescription": "Không thể xác minh tính xác thực của máy chủ này. Hãy kiểm tra xem dấu vân tay có khớp với những gì bạn mong đợi hay không.", - "keyChangedDescription": "Khóa máy chủ (host key) của máy chủ này đã thay đổi kể từ lần kết nối cuối cùng của bạn. Điều này có thể cho thấy một vấn đề về bảo mật.", - "previousKey": "Phím trước đó", - "newFingerprint": "Dấu vân tay mới", - "fingerprint": "Dấu vân tay", - "verifyInstructions": "Nếu bạn tin tưởng máy chủ này, hãy nhấp vào Chấp nhận để tiếp tục và lưu dấu vân tay này cho các kết nối trong tương lai.", - "securityWarning": "Cảnh báo an ninh", - "acceptAndContinue": "Chấp nhận và tiếp tục", - "acceptNewKey": "Chấp nhận khóa mới và tiếp tục" - }, "errors": { "notFound": "Không tìm thấy trang", "unauthorized": "Truy cập trái phép", @@ -2063,8 +2026,6 @@ "terminalSettings": "Phần cuối", "hostSidebarSettings": "Máy chủ & Thanh bên", "snippetsSettings": "Những đoạn trích", - "confirmSnippetExecution": "Xác nhận việc thực thi đoạn mã", - "confirmSnippetExecutionDesc": "Hiển thị hộp thoại xác nhận trước khi thực thi các đoạn mã.", "updateSettings": "Cập nhật", "disableUpdateCheck": "Tắt kiểm tra cập nhật", "disableUpdateCheckDesc": "Ngừng kiểm tra các phiên bản mới khi khởi động và trên bảng điều khiển. Giảm số lượng yêu cầu mạng.", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 hoặc example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/zh_CN.json b/src/locales/translated/zh_CN.json index dc87b069..b429a2c2 100644 --- a/src/locales/translated/zh_CN.json +++ b/src/locales/translated/zh_CN.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "全权证书", "credentialsViewer": "凭证查看器", "manageYourSSHCredentials": "安全地管理您的 SSH 凭证", "addCredential": "添加凭证", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "这将安全地将公钥添加到目标主机的 ~/.ssh/authorized_keys 文件中,而不会覆盖现有密钥。此操作可逆。", "chooseHostToDeploy": "选择要部署到的主机……", "deploying": "正在部署……", - "copyDeployCommand": "复制部署命令", - "copyDeployCommandDescription": "复制 shell 命令手动将公钥添加到主机的授权键文件。当密码验证被禁用时有用。", - "deployCommandCopied": "部署命令已复制到剪贴板", - "manualDeployInfo": "将此命令粘贴到目标主机终端中(例如,Proxmox console、 IPMI 或物理访问) 以添加 SSH 密钥。", "name": "名称", "noHostsAvailable": "暂无可用主机", "noHostsMatchSearch": "没有符合您搜索条件的主机", @@ -303,9 +298,7 @@ "createFolder": "创建文件夹", "editFolder": "编辑文件夹", "editFolderDescription": "自定义代码片段文件夹", - "createFolderDescription": "将你的代码片段归类到文件夹中", - "confirmExecution": "执行“{{name}}”?", - "confirmExecutionDesc": "您确定要执行此代码片段吗?" + "createFolderDescription": "将你的代码片段归类到文件夹中" }, "commandHistory": { "title": "历史", @@ -409,7 +402,6 @@ "required": "必需的", "optional": "选修的", "connect": "连接", - "copied": "已复制", "connecting": "正在连接...", "creating": "正在创建……", "clear": "清除", @@ -502,7 +494,6 @@ "checking": "检查...", "checkingDatabase": "正在检查数据库连接...", "checkingAuthentication": "正在检查认证...", - "backendReconnected": "服务器连接已恢复", "actions": "激活", "remove": "移除", "revoke": "撤销", @@ -815,7 +806,6 @@ }, "hosts": { "title": "主机管理", - "hosts": "主机", "sshHosts": "SSH主机", "noHosts": "无 SSH 主机", "noHostsMessage": "您尚未添加任何 SSH 主机。点击“添加主机”开始操作。", @@ -846,7 +836,7 @@ "failedToImportJson": "导入 JSON 文件失败", "connectionDetails": "连接详情", "organization": "组织", - "ipAddress": "IP地址或主机名", + "ipAddress": "IP地址", "port": "端口", "name": "名称", "username": "用户名", @@ -925,8 +915,6 @@ "key": "密钥", "credential": "凭证", "none": "无", - "opkssh": "打开", - "opksshAuthDescription": "在每个连接上提示您使用 OPKSSH web-auth。您的认证令牌将持续24个小时,直到它被更新。", "selectCredential": "选择凭证", "selectCredentialPlaceholder": "选择一种凭证……", "credentialRequired": "使用凭证身份验证时需要凭证。", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "键盘交互认证", "noneAuthDescription": "连接到 SSH 服务器时,此身份验证方法将使用键盘交互式身份验证。", "noneAuthDetails": "键盘交互式身份验证允许服务器在连接过程中提示您输入凭证。这对于需要多因素身份验证的服务器或您不想在本地保存凭证的情况非常有用。", - "opksshAuthTitle": "OPKSSH 身份验证", "forceKeyboardInteractive": "强制键盘交互", "forceKeyboardInteractiveDesc": "强制使用键盘交互式身份验证。对于使用双因素身份验证(TOTP/2FA)的服务器,有时需要这样做。", "overrideCredentialUsername": "覆盖凭证用户名", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "在浏览器中打开", "warpgateContinue": "我已经完成身份验证", "warpgateTimeout": "Warpgate身份验证超时,请重新连接。", - "opksshAuthRequired": "需要OPKSSH 身份验证", - "opksshAuthDescription": "在您的浏览器中完成身份验证以继续。此会话将持续24小时有效。", - "opksshAuthUrl": "身份验证URL", - "opksshOpenBrowser": "打开浏览器进行身份验证", - "opksshWaitingForAuth": "正在等待浏览器的身份验证...", - "opksshAuthenticating": "正在处理身份验证...", - "opksshTimeout": "认证超时。请重试。", - "opksshAuthFailed": "认证失败。请检查您的凭据,然后重试。", - "opksshConfigMissing": "找不到 OPKSSH 配置。请使用您的 OIDC 提供商设置创建 ~/.opk/config.yml 。请参阅文档:https://github.com/openpubkey/opkssh#config.", "sudoPasswordPopupTitle": "输入密码?", "sudoPasswordPopupHint": "按 Enter 键插入,按 Esc 键关闭", "sudoPasswordPopupConfirm": "确认", @@ -1359,8 +1337,7 @@ "automaticFallback": "正在自动尝试 {{method}} 身份验证...", "totpTimeout": "TOTP验证超时,请重新连接。", "passwordTimeout": "密码验证超时,请重新连接。", - "connectionRejected": "服务器拒绝连接。请检查您的身份验证和网络配置。", - "hostKeyRejected": "SSH 主机验证被拒绝。连接已取消。" + "connectionRejected": "服务器拒绝连接。请检查您的身份验证和网络配置。" }, "fileManager": { "title": "文件管理器", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "密码重置成功", "passwordResetSuccessDesc": "您的密码已成功重置。您现在可以使用新密码登录。" }, - "hostKey": { - "verifyNewHost": "验证 SSH 主机密钥", - "keyChangedWarning": "SSH 主机密钥已更改", - "firstConnectionTitle": "第一次连接到此主机", - "firstConnectionDescription": "此主机的真实性无法确认。请验证指纹符合您的期望。", - "keyChangedDescription": "此服务器的主机密钥自您上次连接以来已经改变。这可能会显示一个安全问题。", - "previousKey": "上一键", - "newFingerprint": "新建指纹", - "fingerprint": "指纹", - "verifyInstructions": "如果您信任此主机,请单击接受以继续保存此指纹以备将来的连接。", - "securityWarning": "安全警告", - "acceptAndContinue": "接受并继续", - "acceptNewKey": "接受新密钥并继续" - }, "errors": { "notFound": "页面未找到", "unauthorized": "未经授权的访问", @@ -2063,8 +2026,6 @@ "terminalSettings": "终端", "hostSidebarSettings": "主机和侧边栏", "snippetsSettings": "代码片段", - "confirmSnippetExecution": "确认代码片段执行", - "confirmSnippetExecutionDesc": "执行代码片段前显示确认对话框", "updateSettings": "更新", "disableUpdateCheck": "禁用更新检查", "disableUpdateCheckDesc": "停止在启动和控制面板中检查新版本。减少网络请求。", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 或 example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/locales/translated/zh_TW.json b/src/locales/translated/zh_TW.json index 2404f242..a526b766 100644 --- a/src/locales/translated/zh_TW.json +++ b/src/locales/translated/zh_TW.json @@ -1,6 +1,5 @@ { "credentials": { - "credentials": "證書", "credentialsViewer": "憑證檢視器", "manageYourSSHCredentials": "安全地管理您的 SSH 憑證", "addCredential": "新增憑證", @@ -100,10 +99,6 @@ "deploymentProcessDescription": "這將安全地將公鑰新增至目標主機的 ~/.ssh/authorized_keys 檔案中,而不會覆蓋現有金鑰。此操作可逆。", "chooseHostToDeploy": "選擇要部署到的主機…", "deploying": "正在部署…", - "copyDeployCommand": "複製部署命令", - "copyDeployCommandDescription": "複製一條 shell 指令,用於手動將公鑰新增至主機的 authorized_keys 檔案中。當密碼驗證被停用時,此方法非常有用。", - "deployCommandCopied": "部署指令已複製到剪貼簿", - "manualDeployInfo": "將此命令貼上到目標主機的終端(例如,Proxmox 控制台、IPMI 或實體存取)中,以新增 SSH 金鑰。", "name": "姓名", "noHostsAvailable": "暫無可用主機", "noHostsMatchSearch": "沒有符合您搜尋條件的主機", @@ -303,9 +298,7 @@ "createFolder": "建立資料夾", "editFolder": "編輯資料夾", "editFolderDescription": "自訂程式碼片段資料夾", - "createFolderDescription": "將你的程式碼片段整理到資料夾中", - "confirmExecution": "執行“{{name}}”?", - "confirmExecutionDesc": "您確定要執行此程式碼片段嗎?" + "createFolderDescription": "將你的程式碼片段整理到資料夾中" }, "commandHistory": { "title": "歷史", @@ -409,7 +402,6 @@ "required": "必需的", "optional": "選擇性", "connect": "連接", - "copied": "已複製", "connecting": "正在連接...", "creating": "正在建立…", "clear": "清除", @@ -502,7 +494,6 @@ "checking": "檢查...", "checkingDatabase": "正在檢查資料庫連線...", "checkingAuthentication": "正在檢查身份驗證...", - "backendReconnected": "伺服器連線已恢復", "actions": "行動", "remove": "消除", "revoke": "撤銷", @@ -815,7 +806,6 @@ }, "hosts": { "title": "主機管理器", - "hosts": "主持人", "sshHosts": "SSH 主機", "noHosts": "無 SSH 主機", "noHostsMessage": "您尚未新增任何 SSH 主機。點選「新增主機」開始操作。", @@ -846,7 +836,7 @@ "failedToImportJson": "導入 JSON 檔案失敗", "connectionDetails": "連線詳情", "organization": "組織", - "ipAddress": "IP位址或主機名", + "ipAddress": "IP位址", "port": "連接埠", "name": "姓名", "username": "使用者名稱", @@ -925,8 +915,6 @@ "key": "金鑰", "credential": "憑證", "none": "沒有", - "opkssh": "OPKSSH", - "opksshAuthDescription": "每次連線時都會提示您進行 OPKSSH Web 驗證。您的身份驗證令牌有效期為 24 小時,之後需要更新。", "selectCredential": "選擇憑證", "selectCredentialPlaceholder": "選擇一種憑證…", "credentialRequired": "使用憑證驗證時需要憑證。", @@ -1095,7 +1083,6 @@ "noneAuthTitle": "鍵盤互動式驗證", "noneAuthDescription": "連接到 SSH 伺服器時,此身份驗證方法將使用鍵盤互動式身份驗證。", "noneAuthDetails": "鍵盤互動式驗證可讓伺服器在連線過程中提示您輸入憑證。這對於需要多因素身份驗證的伺服器或您不想在本地儲存憑證的情況非常有用。", - "opksshAuthTitle": "OPKSSH 驗證", "forceKeyboardInteractive": "強制鍵盤交互", "forceKeyboardInteractiveDesc": "強制使用鍵盤互動式身份驗證。對於使用雙重認證(TOTP/2FA)的伺服器,有時需要這樣做。", "overrideCredentialUsername": "覆蓋憑證使用者名稱", @@ -1329,15 +1316,6 @@ "warpgateOpenBrowser": "在瀏覽器中開啟", "warpgateContinue": "我已經完成身份驗證", "warpgateTimeout": "Warpgate身份驗證逾時,請重新連線。", - "opksshAuthRequired": "需要 OPKSSH 驗證", - "opksshAuthDescription": "請在瀏覽器中完成身份驗證以繼續。此會話有效期限為 24 小時。", - "opksshAuthUrl": "身份驗證 URL", - "opksshOpenBrowser": "開啟瀏覽器進行身份驗證", - "opksshWaitingForAuth": "正在等待瀏覽器驗證...", - "opksshAuthenticating": "正在處理身份驗證...", - "opksshTimeout": "身份驗證超時,請重試。", - "opksshAuthFailed": "身份驗證失敗。請檢查您的憑證並重試。", - "opksshConfigMissing": "未找到 OPKSSH 配置。請建立 ~/.opk/config.yml 文件,並新增您的 OIDC 提供者設定。請參閱文件:https://github.com/openpubkey/opkssh#configuration", "sudoPasswordPopupTitle": "輸入密碼?", "sudoPasswordPopupHint": "按 Enter 鍵插入,按 Esc 鍵關閉", "sudoPasswordPopupConfirm": "插入", @@ -1359,8 +1337,7 @@ "automaticFallback": "正在自動嘗試 {{method}} 驗證...", "totpTimeout": "TOTP驗證逾時,請重新連線。", "passwordTimeout": "密碼驗證逾時,請重新連線。", - "connectionRejected": "伺服器拒絕連線。請檢查您的身份驗證和網路配置。", - "hostKeyRejected": "SSH主機金鑰驗證失敗。連線已取消。" + "connectionRejected": "伺服器拒絕連線。請檢查您的身份驗證和網路配置。" }, "fileManager": { "title": "檔案管理器", @@ -1957,20 +1934,6 @@ "passwordResetSuccess": "密碼重置成功", "passwordResetSuccessDesc": "您的密碼已成功重設。現在您可以使用新密碼登入。" }, - "hostKey": { - "verifyNewHost": "驗證 SSH 主機金鑰", - "keyChangedWarning": "SSH主機金鑰已更改", - "firstConnectionTitle": "首次連線到此主機", - "firstConnectionDescription": "無法確認此主機的真實性。請驗證指紋是否符合預期。", - "keyChangedDescription": "此伺服器的主機金鑰自您上次連線以來已變更。這可能表示存在安全問題。", - "previousKey": "上一個密鑰", - "newFingerprint": "新指紋", - "fingerprint": "指紋", - "verifyInstructions": "如果您信任此主機,請按一下「接受」繼續並儲存此指紋以便將來連線。", - "securityWarning": "安全警告", - "acceptAndContinue": "接受並繼續", - "acceptNewKey": "接受新密鑰並繼續" - }, "errors": { "notFound": "頁面未找到", "unauthorized": "未經授權的訪問", @@ -2063,8 +2026,6 @@ "terminalSettings": "終端機", "hostSidebarSettings": "主機和側邊欄", "snippetsSettings": "片段", - "confirmSnippetExecution": "確認程式碼片段執行", - "confirmSnippetExecutionDesc": "執行程式碼片段前顯示確認對話框", "updateSettings": "更新", "disableUpdateCheck": "禁用更新檢查", "disableUpdateCheckDesc": "停止在啟動和控制面板中檢查新版本。減少網路請求。", @@ -2085,7 +2046,7 @@ }, "placeholders": { "enterCode": "000000", - "ipAddress": "192.168.1.1 或 example.com", + "ipAddress": "127.0.0.1", "port": "22", "maxRetries": "3", "retryInterval": "10", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ec3321e4..6ded0abc 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -88,5 +88,9 @@ declare global { interface Window { electronAPI: ElectronAPI; IS_ELECTRON: boolean; + electronClipboard?: { + writeText(text: string): void; + readText(): string; + }; } } diff --git a/src/types/index.ts b/src/types/index.ts index 843704dd..48b81e62 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -82,7 +82,7 @@ export interface QuickActionData { export interface ProxyNode { host: string; port: number; - type: 4 | 5; + type: 4 | 5 | "http"; username?: string; password?: string; } @@ -173,9 +173,9 @@ export interface CredentialBackend { username: string | null; password: string | null; key: string; - private_key?: string; - public_key?: string; - key_password: string | null; + privateKey?: string; + publicKey?: string; + keyPassword: string | null; keyType?: string; detectedKeyType: string; usageCount: number; @@ -395,6 +395,8 @@ export interface TerminalConfig { autoMosh: boolean; moshCommand: string; sudoPasswordAutoFill: boolean; + keepaliveInterval?: number; + keepaliveCountMax?: number; } // ============================================================================ @@ -403,6 +405,7 @@ export interface TerminalConfig { export interface TabContextTab { id: number; + instanceId?: string; type: | "home" | "terminal" diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index f8e18fc0..69aaa31f 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -53,8 +53,10 @@ export interface StatsConfig { enabledWidgets: WidgetType[]; statusCheckEnabled: boolean; statusCheckInterval: number; + useGlobalStatusInterval?: boolean; metricsEnabled: boolean; metricsInterval: number; + useGlobalMetricsInterval?: boolean; } export const DEFAULT_STATS_CONFIG: StatsConfig = { @@ -69,6 +71,8 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = { ], statusCheckEnabled: true, statusCheckInterval: 30, + useGlobalStatusInterval: true, metricsEnabled: true, metricsInterval: 30, + useGlobalMetricsInterval: true, }; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 62e91a70..e9411c95 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -1,4 +1,12 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { + useState, + useEffect, + useCallback, + useRef, + Component, + type ErrorInfo, + type ReactNode, +} from "react"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; @@ -148,8 +156,9 @@ function AppContent({ if (hostIdentifier) { const openTerminal = async () => { try { - const { getSSHHostById, getSSHHosts } = - await import("@/ui/main-axios.ts"); + const { getSSHHostById, getSSHHosts } = await import( + "@/ui/main-axios.ts" + ); let host = null; if (/^\d+$/.test(hostIdentifier)) { @@ -616,16 +625,54 @@ function AppContent({ ); } +class TabErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; errorCount: number } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, errorCount: 0 }; + } + + static getDerivedStateFromError(error: Error) { + if (error.message?.includes("useTabs must be used within a TabProvider")) { + return { hasError: true }; + } + throw error; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (error.message?.includes("useTabs must be used within a TabProvider")) { + console.warn( + "TabProvider mounting race condition detected, recovering...", + ); + this.setState((prev) => ({ errorCount: prev.errorCount + 1 })); + setTimeout(() => { + this.setState({ hasError: false }); + }, 0); + } + } + + render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + function DesktopApp() { const [isAuthenticated, setIsAuthenticated] = useState(false); return ( - - - - - + + + + + + + ); } diff --git a/src/ui/desktop/apps/admin/AdminSettings.tsx b/src/ui/desktop/apps/admin/AdminSettings.tsx index 878efdd8..0ef8ab53 100644 --- a/src/ui/desktop/apps/admin/AdminSettings.tsx +++ b/src/ui/desktop/apps/admin/AdminSettings.tsx @@ -61,6 +61,7 @@ export function AdminSettings({ name_path: "name", scopes: "openid email profile", userinfo_url: "", + allowed_users: "", }); const [users, setUsers] = React.useState< diff --git a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx b/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx index 1ca5c436..5c38955f 100644 --- a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx +++ b/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx @@ -31,15 +31,22 @@ export function DatabaseSecurityTab({ [currentUser?.is_oidc], ); - const handleExportDatabase = async () => { - if (!showPasswordInput) { - setShowPasswordInput(true); - return; - } + const requiresExportPassword = React.useMemo( + () => !currentUser?.is_oidc, + [currentUser?.is_oidc], + ); - if (!exportPassword.trim()) { - toast.error(t("admin.passwordRequired")); - return; + const handleExportDatabase = async () => { + if (requiresExportPassword) { + if (!showPasswordInput) { + setShowPasswordInput(true); + return; + } + + if (!exportPassword.trim()) { + toast.error(t("admin.passwordRequired")); + return; + } } setExportLoading(true); @@ -65,7 +72,9 @@ export function DatabaseSecurityTab({ "Content-Type": "application/json", }, credentials: "include", - body: JSON.stringify({ password: exportPassword }), + body: JSON.stringify( + requiresExportPassword ? { password: exportPassword } : {}, + ), }); if (response.ok) { @@ -211,7 +220,7 @@ export function DatabaseSecurityTab({

{t("admin.exportDescription")}

- {showPasswordInput && ( + {showPasswordInput && requiresExportPassword && (
{exportLoading ? t("admin.exporting") - : showPasswordInput + : showPasswordInput && requiresExportPassword ? t("admin.confirmExport") : t("admin.export")} - {showPasswordInput && ( + {showPasswordInput && requiresExportPassword && (