mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
536dc35258
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
* 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+<key> as an alternative that sends Ctrl+<key>.
- 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 <bugattiguy527@gmail.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
* 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 <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
* 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
---------
Co-authored-by: Steven Josefs <s.josefs@gmx.de>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com>
1252 lines
34 KiB
TypeScript
1252 lines
34 KiB
TypeScript
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import express from "express";
|
|
import { db } from "../db/index.js";
|
|
import { snippets, snippetFolders } from "../db/schema.js";
|
|
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
|
import type { Request, Response } from "express";
|
|
import { authLogger } from "../../utils/logger.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
|
|
const router = express.Router();
|
|
|
|
function isNonEmptyString(val: unknown): val is string {
|
|
return typeof val === "string" && val.trim().length > 0;
|
|
}
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/folders:
|
|
* get:
|
|
* summary: Get all snippet folders
|
|
* description: Retrieves all snippet folders for the authenticated user.
|
|
* tags:
|
|
* - Snippets
|
|
* responses:
|
|
* 200:
|
|
* description: A list of snippet folders.
|
|
* 400:
|
|
* description: Invalid userId.
|
|
* 500:
|
|
* description: Failed to fetch snippet folders.
|
|
*/
|
|
router.get(
|
|
"/folders",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId for snippet folders fetch");
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
try {
|
|
const result = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(eq(snippetFolders.userId, userId))
|
|
.orderBy(asc(snippetFolders.name));
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch snippet folders", err);
|
|
res.status(500).json({ error: "Failed to fetch snippet folders" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/folders:
|
|
* post:
|
|
* summary: Create a new snippet folder
|
|
* description: Creates a new snippet folder for the authenticated user.
|
|
* tags:
|
|
* - Snippets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* color:
|
|
* type: string
|
|
* icon:
|
|
* type: string
|
|
* responses:
|
|
* 201:
|
|
* description: Snippet folder created successfully.
|
|
* 400:
|
|
* description: Folder name is required.
|
|
* 409:
|
|
* description: Folder with this name already exists.
|
|
* 500:
|
|
* description: Failed to create snippet folder.
|
|
*/
|
|
router.post(
|
|
"/folders",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { name, color, icon } = req.body;
|
|
|
|
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
|
authLogger.warn("Invalid snippet folder creation data", {
|
|
operation: "snippet_folder_create",
|
|
userId,
|
|
hasName: !!name,
|
|
});
|
|
return res.status(400).json({ error: "Folder name is required" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(
|
|
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
|
);
|
|
|
|
if (existing.length > 0) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: "Folder with this name already exists" });
|
|
}
|
|
|
|
const insertData = {
|
|
userId,
|
|
name: name.trim(),
|
|
color: color?.trim() || null,
|
|
icon: icon?.trim() || null,
|
|
};
|
|
|
|
const result = await db
|
|
.insert(snippetFolders)
|
|
.values(insertData)
|
|
.returning();
|
|
|
|
authLogger.success(`Snippet folder created: ${name} by user ${userId}`, {
|
|
operation: "snippet_folder_create_success",
|
|
userId,
|
|
name,
|
|
});
|
|
|
|
res.status(201).json(result[0]);
|
|
} catch (err) {
|
|
authLogger.error("Failed to create snippet folder", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to create snippet folder",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/folders/{name}/metadata:
|
|
* put:
|
|
* summary: Update snippet folder metadata
|
|
* description: Updates the metadata (color, icon) of a snippet folder.
|
|
* tags:
|
|
* - Snippets
|
|
* parameters:
|
|
* - in: path
|
|
* name: name
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* color:
|
|
* type: string
|
|
* icon:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Snippet folder metadata updated successfully.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 404:
|
|
* description: Folder not found.
|
|
* 500:
|
|
* description: Failed to update snippet folder metadata.
|
|
*/
|
|
router.put(
|
|
"/folders/:name/metadata",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const name = Array.isArray(req.params.name)
|
|
? req.params.name[0]
|
|
: req.params.name;
|
|
const { color, icon } = req.body;
|
|
|
|
if (!isNonEmptyString(userId) || !name) {
|
|
authLogger.warn("Invalid request for snippet folder metadata update");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, decodeURIComponent(name)),
|
|
),
|
|
);
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "Folder not found" });
|
|
}
|
|
|
|
const updateFields: Partial<{
|
|
color: string | null;
|
|
icon: string | null;
|
|
updatedAt: ReturnType<typeof sql.raw>;
|
|
}> = {
|
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
|
};
|
|
|
|
if (color !== undefined) updateFields.color = color?.trim() || null;
|
|
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
|
|
|
await db
|
|
.update(snippetFolders)
|
|
.set(updateFields)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, decodeURIComponent(name)),
|
|
),
|
|
);
|
|
|
|
const updated = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, decodeURIComponent(name)),
|
|
),
|
|
);
|
|
|
|
authLogger.success(
|
|
`Snippet folder metadata updated: ${name} by user ${userId}`,
|
|
{
|
|
operation: "snippet_folder_metadata_update_success",
|
|
userId,
|
|
name,
|
|
},
|
|
);
|
|
|
|
res.json(updated[0]);
|
|
} catch (err) {
|
|
authLogger.error("Failed to update snippet folder metadata", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to update snippet folder metadata",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/folders/rename:
|
|
* put:
|
|
* summary: Rename a snippet folder
|
|
* description: Renames a snippet folder for the authenticated user.
|
|
* tags:
|
|
* - Snippets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* oldName:
|
|
* type: string
|
|
* newName:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Folder renamed successfully.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 404:
|
|
* description: Folder not found.
|
|
* 409:
|
|
* description: Folder with new name already exists.
|
|
* 500:
|
|
* description: Failed to rename snippet folder.
|
|
*/
|
|
router.put(
|
|
"/folders/rename",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { oldName, newName } = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(oldName) ||
|
|
!isNonEmptyString(newName)
|
|
) {
|
|
authLogger.warn("Invalid request for snippet folder rename");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, oldName),
|
|
),
|
|
);
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "Folder not found" });
|
|
}
|
|
|
|
const nameExists = await db
|
|
.select()
|
|
.from(snippetFolders)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, newName),
|
|
),
|
|
);
|
|
|
|
if (nameExists.length > 0) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: "Folder with new name already exists" });
|
|
}
|
|
|
|
await db
|
|
.update(snippetFolders)
|
|
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, oldName),
|
|
),
|
|
);
|
|
|
|
await db
|
|
.update(snippets)
|
|
.set({ folder: newName })
|
|
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
|
|
|
|
authLogger.success(
|
|
`Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`,
|
|
{
|
|
operation: "snippet_folder_rename_success",
|
|
userId,
|
|
oldName,
|
|
newName,
|
|
},
|
|
);
|
|
|
|
res.json({ success: true, oldName, newName });
|
|
} catch (err) {
|
|
authLogger.error("Failed to rename snippet folder", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to rename snippet folder",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/folders/{name}:
|
|
* delete:
|
|
* summary: Delete a snippet folder
|
|
* description: Deletes a snippet folder and moves its snippets to the root.
|
|
* tags:
|
|
* - Snippets
|
|
* parameters:
|
|
* - in: path
|
|
* name: name
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Snippet folder deleted successfully.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 500:
|
|
* description: Failed to delete snippet folder.
|
|
*/
|
|
router.delete(
|
|
"/folders/:name",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const name = Array.isArray(req.params.name)
|
|
? req.params.name[0]
|
|
: req.params.name;
|
|
|
|
if (!isNonEmptyString(userId) || !name) {
|
|
authLogger.warn("Invalid request for snippet folder delete");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const folderName = decodeURIComponent(name);
|
|
|
|
await db
|
|
.update(snippets)
|
|
.set({ folder: null })
|
|
.where(
|
|
and(eq(snippets.userId, userId), eq(snippets.folder, folderName)),
|
|
);
|
|
|
|
await db
|
|
.delete(snippetFolders)
|
|
.where(
|
|
and(
|
|
eq(snippetFolders.userId, userId),
|
|
eq(snippetFolders.name, folderName),
|
|
),
|
|
);
|
|
|
|
authLogger.success(
|
|
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
|
{
|
|
operation: "snippet_folder_delete_success",
|
|
userId,
|
|
name: folderName,
|
|
},
|
|
);
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete snippet folder", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to delete snippet folder",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/reorder:
|
|
* put:
|
|
* summary: Reorder snippets
|
|
* description: Bulk updates the order and folder of snippets.
|
|
* tags:
|
|
* - Snippets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* snippets:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: integer
|
|
* order:
|
|
* type: integer
|
|
* folder:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Snippets reordered successfully.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 500:
|
|
* description: Failed to reorder snippets.
|
|
*/
|
|
router.put(
|
|
"/reorder",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { snippets: snippetUpdates } = req.body;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId for snippet reorder");
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
|
|
authLogger.warn("Invalid snippet reorder data", {
|
|
operation: "snippet_reorder",
|
|
userId,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: "snippets array is required and must not be empty" });
|
|
}
|
|
|
|
try {
|
|
for (const update of snippetUpdates) {
|
|
const { id, order, folder } = update;
|
|
|
|
if (!id || order === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const updateFields: Partial<{
|
|
order: number;
|
|
folder: string | null;
|
|
}> = {
|
|
order,
|
|
};
|
|
|
|
if (folder !== undefined) {
|
|
updateFields.folder = folder?.trim() || null;
|
|
}
|
|
|
|
await db
|
|
.update(snippets)
|
|
.set(updateFields)
|
|
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
|
|
}
|
|
|
|
authLogger.success(`Snippets reordered by user ${userId}`, {
|
|
operation: "snippet_reorder_success",
|
|
userId,
|
|
count: snippetUpdates.length,
|
|
});
|
|
|
|
res.json({ success: true, updated: snippetUpdates.length });
|
|
} catch (err) {
|
|
authLogger.error("Failed to reorder snippets", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to reorder snippets",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/execute:
|
|
* post:
|
|
* summary: Execute a snippet on a host
|
|
* description: Executes a snippet on a specified host.
|
|
* tags:
|
|
* - Snippets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* snippetId:
|
|
* type: integer
|
|
* hostId:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Snippet executed successfully.
|
|
* 400:
|
|
* description: Snippet ID and Host ID are required.
|
|
* 404:
|
|
* description: Snippet or host not found.
|
|
* 500:
|
|
* description: Failed to execute snippet.
|
|
*/
|
|
router.post(
|
|
"/execute",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { snippetId, hostId } = req.body;
|
|
|
|
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
|
|
authLogger.warn("Invalid snippet execution request", {
|
|
userId,
|
|
snippetId,
|
|
hostId,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Snippet ID and Host ID are required" });
|
|
}
|
|
|
|
try {
|
|
const snippetResult = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(
|
|
and(
|
|
eq(snippets.id, parseInt(snippetId)),
|
|
eq(snippets.userId, userId),
|
|
),
|
|
);
|
|
|
|
if (snippetResult.length === 0) {
|
|
return res.status(404).json({ error: "Snippet not found" });
|
|
}
|
|
|
|
const snippet = snippetResult[0];
|
|
|
|
const { Client } = await import("ssh2");
|
|
const { sshData, sshCredentials } = await import("../db/schema.js");
|
|
|
|
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
|
|
|
const hostResult = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshData)
|
|
.where(
|
|
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
|
),
|
|
"ssh_data",
|
|
userId,
|
|
);
|
|
|
|
if (hostResult.length === 0) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
const host = hostResult[0];
|
|
|
|
let password = host.password;
|
|
let privateKey = host.key;
|
|
let passphrase = host.key_password;
|
|
let authType = host.authType;
|
|
|
|
if (host.credentialId) {
|
|
const credResult = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, host.credentialId as number),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (credResult.length > 0) {
|
|
const cred = credResult[0];
|
|
authType = (cred.auth_type || cred.authType || authType) as string;
|
|
password = (cred.password || undefined) as string | undefined;
|
|
privateKey = (cred.private_key || cred.key || undefined) as
|
|
| string
|
|
| undefined;
|
|
passphrase = (cred.key_password || undefined) as string | undefined;
|
|
}
|
|
}
|
|
|
|
const conn = new Client();
|
|
let output = "";
|
|
let errorOutput = "";
|
|
|
|
const executePromise = new Promise<{
|
|
success: boolean;
|
|
output: string;
|
|
error?: string;
|
|
}>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
conn.end();
|
|
reject(new Error("Command execution timeout (30s)"));
|
|
}, 30000);
|
|
|
|
conn.on("ready", () => {
|
|
conn.exec(snippet.content, (err, stream) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
return reject(err);
|
|
}
|
|
|
|
stream.on("close", () => {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
if (errorOutput) {
|
|
resolve({ success: false, output, error: errorOutput });
|
|
} else {
|
|
resolve({ success: true, output });
|
|
}
|
|
});
|
|
|
|
stream.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.stderr.on("data", (data: Buffer) => {
|
|
errorOutput += data.toString();
|
|
});
|
|
});
|
|
});
|
|
|
|
conn.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
|
|
const config: any = {
|
|
host: host.ip,
|
|
port: host.port,
|
|
username: host.username,
|
|
tryKeyboard: true,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 3,
|
|
readyTimeout: 30000,
|
|
tcpKeepAlive: true,
|
|
tcpKeepAliveInitialDelay: 30000,
|
|
timeout: 30000,
|
|
env: {
|
|
TERM: "xterm-256color",
|
|
LANG: "en_US.UTF-8",
|
|
LC_ALL: "en_US.UTF-8",
|
|
LC_CTYPE: "en_US.UTF-8",
|
|
LC_MESSAGES: "en_US.UTF-8",
|
|
LC_MONETARY: "en_US.UTF-8",
|
|
LC_NUMERIC: "en_US.UTF-8",
|
|
LC_TIME: "en_US.UTF-8",
|
|
LC_COLLATE: "en_US.UTF-8",
|
|
COLORTERM: "truecolor",
|
|
},
|
|
algorithms: {
|
|
kex: [
|
|
"curve25519-sha256",
|
|
"curve25519-sha256@libssh.org",
|
|
"ecdh-sha2-nistp521",
|
|
"ecdh-sha2-nistp384",
|
|
"ecdh-sha2-nistp256",
|
|
"diffie-hellman-group-exchange-sha256",
|
|
"diffie-hellman-group14-sha256",
|
|
"diffie-hellman-group14-sha1",
|
|
"diffie-hellman-group-exchange-sha1",
|
|
"diffie-hellman-group1-sha1",
|
|
],
|
|
serverHostKey: [
|
|
"ssh-ed25519",
|
|
"ecdsa-sha2-nistp521",
|
|
"ecdsa-sha2-nistp384",
|
|
"ecdsa-sha2-nistp256",
|
|
"rsa-sha2-512",
|
|
"rsa-sha2-256",
|
|
"ssh-rsa",
|
|
"ssh-dss",
|
|
],
|
|
cipher: [
|
|
"chacha20-poly1305@openssh.com",
|
|
"aes256-gcm@openssh.com",
|
|
"aes128-gcm@openssh.com",
|
|
"aes256-ctr",
|
|
"aes192-ctr",
|
|
"aes128-ctr",
|
|
"aes256-cbc",
|
|
"aes192-cbc",
|
|
"aes128-cbc",
|
|
"3des-cbc",
|
|
],
|
|
hmac: [
|
|
"hmac-sha2-512-etm@openssh.com",
|
|
"hmac-sha2-256-etm@openssh.com",
|
|
"hmac-sha2-512",
|
|
"hmac-sha2-256",
|
|
"hmac-sha1",
|
|
"hmac-md5",
|
|
],
|
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
|
},
|
|
};
|
|
|
|
if (authType === "password" && password) {
|
|
config.password = password;
|
|
} else if (authType === "key" && privateKey) {
|
|
const cleanKey = (privateKey as string)
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
config.privateKey = Buffer.from(cleanKey, "utf8");
|
|
if (passphrase) {
|
|
config.passphrase = passphrase;
|
|
}
|
|
} else if (password) {
|
|
config.password = password;
|
|
} else if (privateKey) {
|
|
const cleanKey = (privateKey as string)
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
config.privateKey = Buffer.from(cleanKey, "utf8");
|
|
if (passphrase) {
|
|
config.passphrase = passphrase;
|
|
}
|
|
}
|
|
|
|
conn.connect(config);
|
|
});
|
|
|
|
const result = await executePromise;
|
|
|
|
authLogger.success(
|
|
`Snippet executed: ${snippet.name} on host ${hostId}`,
|
|
{
|
|
operation: "snippet_execute_success",
|
|
userId,
|
|
snippetId,
|
|
hostId,
|
|
},
|
|
);
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
authLogger.error("Failed to execute snippet", err);
|
|
res.status(500).json({
|
|
error: err instanceof Error ? err.message : "Failed to execute snippet",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets:
|
|
* get:
|
|
* summary: Get all snippets
|
|
* description: Retrieves all snippets for the authenticated user.
|
|
* tags:
|
|
* - Snippets
|
|
* responses:
|
|
* 200:
|
|
* description: A list of snippets.
|
|
* 400:
|
|
* description: Invalid userId.
|
|
* 500:
|
|
* description: Failed to fetch snippets.
|
|
*/
|
|
router.get(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId for snippets fetch");
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
try {
|
|
const result = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(eq(snippets.userId, userId))
|
|
.orderBy(
|
|
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
|
|
asc(snippets.folder),
|
|
asc(snippets.order),
|
|
desc(snippets.updatedAt),
|
|
);
|
|
|
|
res.json(result);
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch snippets", err);
|
|
res.status(500).json({ error: "Failed to fetch snippets" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/{id}:
|
|
* get:
|
|
* summary: Get a specific snippet
|
|
* description: Retrieves a specific snippet by its ID.
|
|
* tags:
|
|
* - Snippets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The requested snippet.
|
|
* 400:
|
|
* description: Invalid request parameters.
|
|
* 404:
|
|
* description: Snippet not found.
|
|
* 500:
|
|
* description: Failed to fetch snippet.
|
|
*/
|
|
router.get(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const snippetId = parseInt(id, 10);
|
|
|
|
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
|
|
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
|
|
userId,
|
|
id,
|
|
});
|
|
return res.status(400).json({ error: "Invalid request parameters" });
|
|
}
|
|
|
|
try {
|
|
const result = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
|
|
|
if (result.length === 0) {
|
|
return res.status(404).json({ error: "Snippet not found" });
|
|
}
|
|
|
|
res.json(result[0]);
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch snippet", err);
|
|
res.status(500).json({
|
|
error: err instanceof Error ? err.message : "Failed to fetch snippet",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets:
|
|
* post:
|
|
* summary: Create a new snippet
|
|
* description: Creates a new snippet for the authenticated user.
|
|
* tags:
|
|
* - Snippets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* content:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* folder:
|
|
* type: string
|
|
* order:
|
|
* type: integer
|
|
* responses:
|
|
* 201:
|
|
* description: Snippet created successfully.
|
|
* 400:
|
|
* description: Name and content are required.
|
|
* 500:
|
|
* description: Failed to create snippet.
|
|
*/
|
|
router.post(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { name, content, description, folder, order } = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(name) ||
|
|
!isNonEmptyString(content)
|
|
) {
|
|
authLogger.warn("Invalid snippet creation data validation failed", {
|
|
operation: "snippet_create",
|
|
userId,
|
|
hasName: !!name,
|
|
hasContent: !!content,
|
|
});
|
|
return res.status(400).json({ error: "Name and content are required" });
|
|
}
|
|
|
|
try {
|
|
let snippetOrder = order;
|
|
if (snippetOrder === undefined || snippetOrder === null) {
|
|
const folderValue = folder?.trim() || "";
|
|
const maxOrderResult = await db
|
|
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
|
.from(snippets)
|
|
.where(
|
|
and(
|
|
eq(snippets.userId, userId),
|
|
folderValue
|
|
? eq(snippets.folder, folderValue)
|
|
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
|
),
|
|
);
|
|
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
|
|
snippetOrder = maxOrder + 1;
|
|
}
|
|
|
|
const insertData = {
|
|
userId,
|
|
name: name.trim(),
|
|
content: content.trim(),
|
|
description: description?.trim() || null,
|
|
folder: folder?.trim() || null,
|
|
order: snippetOrder,
|
|
};
|
|
|
|
const result = await db.insert(snippets).values(insertData).returning();
|
|
|
|
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
|
|
operation: "snippet_create_success",
|
|
userId,
|
|
snippetId: result[0].id,
|
|
name,
|
|
});
|
|
|
|
res.status(201).json(result[0]);
|
|
} catch (err) {
|
|
authLogger.error("Failed to create snippet", err);
|
|
res.status(500).json({
|
|
error: err instanceof Error ? err.message : "Failed to create snippet",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/{id}:
|
|
* put:
|
|
* summary: Update a snippet
|
|
* description: Updates a specific snippet by its ID.
|
|
* tags:
|
|
* - Snippets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* content:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* folder:
|
|
* type: string
|
|
* order:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The updated snippet.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 404:
|
|
* description: Snippet not found.
|
|
* 500:
|
|
* description: Failed to update snippet.
|
|
*/
|
|
router.put(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const updateData = req.body;
|
|
|
|
if (!isNonEmptyString(userId) || !id) {
|
|
authLogger.warn("Invalid request for snippet update");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "Snippet not found" });
|
|
}
|
|
|
|
const updateFields: Partial<{
|
|
updatedAt: ReturnType<typeof sql.raw>;
|
|
name: string;
|
|
content: string;
|
|
description: string | null;
|
|
folder: string | null;
|
|
order: number;
|
|
}> = {
|
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
|
};
|
|
|
|
if (updateData.name !== undefined)
|
|
updateFields.name = updateData.name.trim();
|
|
if (updateData.content !== undefined)
|
|
updateFields.content = updateData.content.trim();
|
|
if (updateData.description !== undefined)
|
|
updateFields.description = updateData.description?.trim() || null;
|
|
if (updateData.folder !== undefined)
|
|
updateFields.folder = updateData.folder?.trim() || null;
|
|
if (updateData.order !== undefined) updateFields.order = updateData.order;
|
|
|
|
await db
|
|
.update(snippets)
|
|
.set(updateFields)
|
|
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
|
|
|
const updated = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(eq(snippets.id, parseInt(id)));
|
|
|
|
authLogger.success(
|
|
`Snippet updated: ${updated[0].name} by user ${userId}`,
|
|
{
|
|
operation: "snippet_update_success",
|
|
userId,
|
|
snippetId: parseInt(id),
|
|
name: updated[0].name,
|
|
},
|
|
);
|
|
|
|
res.json(updated[0]);
|
|
} catch (err) {
|
|
authLogger.error("Failed to update snippet", err);
|
|
res.status(500).json({
|
|
error: err instanceof Error ? err.message : "Failed to update snippet",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /snippets/{id}:
|
|
* delete:
|
|
* summary: Delete a snippet
|
|
* description: Deletes a specific snippet by its ID.
|
|
* tags:
|
|
* - Snippets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Snippet deleted successfully.
|
|
* 400:
|
|
* description: Invalid request.
|
|
* 404:
|
|
* description: Snippet not found.
|
|
* 500:
|
|
* description: Failed to delete snippet.
|
|
*/
|
|
router.delete(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
if (!isNonEmptyString(userId) || !id) {
|
|
authLogger.warn("Invalid request for snippet delete");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(snippets)
|
|
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "Snippet not found" });
|
|
}
|
|
|
|
await db
|
|
.delete(snippets)
|
|
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
|
|
|
authLogger.success(
|
|
`Snippet deleted: ${existing[0].name} by user ${userId}`,
|
|
{
|
|
operation: "snippet_delete_success",
|
|
userId,
|
|
snippetId: parseInt(id),
|
|
name: existing[0].name,
|
|
},
|
|
);
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete snippet", err);
|
|
res.status(500).json({
|
|
error: err instanceof Error ? err.message : "Failed to delete snippet",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|