├── .gitattributes ├── public ├── assets │ ├── dumbterm.png │ ├── fonts │ │ ├── FiraCodeNerdFontMono-Bold.ttf │ │ ├── FiraCodeNerdFontMono-Light.ttf │ │ ├── FiraCodeNerdFontMono-Medium.ttf │ │ ├── FiraCodeNerdFontMono-Retina.ttf │ │ ├── FiraCodeNerdFontMono-Regular.ttf │ │ └── FiraCodeNerdFontMono-SemiBold.ttf │ └── dumbterm.svg ├── login.html ├── managers │ ├── storage.js │ └── serviceWorker.js ├── index.html ├── login.js ├── index.js ├── service-worker.js └── styles.css ├── nodemon.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── docker-publish.yml ├── .env.example ├── .dockerignore ├── package.json ├── docker-compose.yml ├── entrypoint.sh ├── Dockerfile ├── config └── starship.toml ├── scripts ├── pwa-manifest-generator.js ├── copy-xterm.js ├── cors.js └── demo │ └── terminal.js ├── .gitignore ├── .cursorrules ├── README.md └── server.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/assets/dumbterm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/dumbterm.png -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-Bold.ttf -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-Light.ttf -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-Medium.ttf -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-Retina.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-Retina.ttf -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-Regular.ttf -------------------------------------------------------------------------------- /public/assets/fonts/FiraCodeNerdFontMono-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbTerm/HEAD/public/assets/fonts/FiraCodeNerdFontMono-SemiBold.ttf -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "public/*", 4 | "node_modules/*", 5 | ".git", 6 | "*.log" 7 | ], 8 | "delay": 1000, 9 | "watch": ["server.js", "config/*", "scripts/*"] 10 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | PORT=3000 3 | NODE_ENV=production 4 | DEBUG=TRUE 5 | 6 | # Site Configuration 7 | SITE_TITLE=DumbTerm 8 | 9 | # Base URL Configuration 10 | BASE_URL=http://localhost:3000 11 | 12 | # PIN Protection (Recommended) 13 | DUMBTERM_PIN= 14 | 15 | # PIN Lockout Duration (in minutes - default: 15) 16 | LOCKOUT_TIME= 17 | 18 | # CORS Configuration 19 | ALLOWED_ORIGINS= 20 | 21 | # Max Session Age (in hours - default: 24) requires login after session expires 22 | # MAX_SESSION_AGE=24 23 | 24 | # Set to 'true' to enable demo mode with simulated terminal 25 | # DEMO_MODE=true 26 | 27 | # DOCKER ONLY 28 | # Enable Starship and Nerd Fonts (Docker only) 29 | # ENABLE_STARSHIP= 30 | 31 | # Timezone Configuration (Docker Only) 32 | # DUMBTERM_TZ=America/Los_Angeles -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: Your Informative Title Here 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | 30 | **Smartphone (please complete the following information):** 31 | - Device: [e.g. iPhone6] 32 | - OS: [e.g. iOS8.1] 33 | - Browser [e.g. stock browser, safari] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | yarn.lock 7 | 8 | # Environment 9 | .env 10 | .env.* 11 | *.env 12 | 13 | # Git 14 | .git 15 | .gitignore 16 | .gitattributes 17 | 18 | # IDE and Editor files 19 | .idea 20 | .vscode 21 | *.swp 22 | *.swo 23 | .cursor 24 | .cursor/ 25 | cursorrules 26 | 27 | # OS 28 | .DS_Store 29 | Thumbs.db 30 | *.tmp 31 | *.temp 32 | 33 | # Docker 34 | Dockerfile 35 | .dockerignore 36 | docker-compose* 37 | *.dockerfile 38 | 39 | # Build and Cache 40 | dist/ 41 | build/ 42 | .cache/ 43 | coverage/ 44 | .nyc_output/ 45 | *.tsbuildinfo 46 | 47 | # Data and Logs 48 | data/ 49 | logs/ 50 | *.log 51 | .npm/ 52 | .config/ 53 | 54 | # Test 55 | test/ 56 | __tests__/ 57 | *.test.js 58 | *.spec.js 59 | 60 | # Documentation 61 | README.md 62 | LICENSE 63 | *.md 64 | docs/ 65 | boilerplate.md 66 | reference.md 67 | 68 | # Misc 69 | .editorconfig 70 | .eslintrc* 71 | .prettierrc* 72 | .babelrc* 73 | jest.config* 74 | *.gz 75 | *.zip 76 | *.tar 77 | *.rar -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumbterm", 3 | "version": "1.2.0", 4 | "description": "A stupidly simple web-based terminal emulator, with common tools and Starship enabled (via Docker)! 🚀", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "npm run copy-xterm && node server.js", 8 | "dev": "npm run copy-xterm && nodemon server.js", 9 | "copy-xterm": "node scripts/copy-xterm.js" 10 | }, 11 | "dependencies": { 12 | "@xterm/addon-attach": "^0.11.0", 13 | "@xterm/addon-canvas": "^0.7.0", 14 | "@xterm/addon-clipboard": "^0.1.0", 15 | "@xterm/addon-fit": "^0.10.0", 16 | "@xterm/addon-image": "^0.8.0", 17 | "@xterm/addon-ligatures": "^0.9.0", 18 | "@xterm/addon-search": "^0.15.0", 19 | "@xterm/addon-serialize": "^0.13.0", 20 | "@xterm/addon-unicode11": "^0.8.0", 21 | "@xterm/addon-web-links": "^0.11.0", 22 | "@xterm/addon-webgl": "^0.18.0", 23 | "@xterm/xterm": "^5.5.0", 24 | "cookie-parser": "^1.4.7", 25 | "cors": "^2.8.5", 26 | "dotenv": "^17.2.2", 27 | "express": "^5.1.0", 28 | "express-session": "^1.18.2", 29 | "node-pty": "^1.0.0", 30 | "ws": "^8.18.3" 31 | }, 32 | "devDependencies": { 33 | "nodemon": "^3.1.10" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dumbterm: 3 | # image: dumbwareio/dumbterm:latest 4 | build: . 5 | container_name: dumbterm 6 | restart: unless-stopped 7 | ports: 8 | - ${DUMBTERM_PORT:-3000}:3000 9 | volumes: 10 | - ${DUMBTERM_CONFIG:-./config}:/root/.config 11 | - ${DUMBTERM_DATA_DIR:-./data}:/root/data # Map the data directory to a local folder 12 | environment: 13 | # Container timezone 14 | TZ: ${DUMBTERM_TZ:-America/Los_Angeles} 15 | # The title shown in the web interface 16 | SITE_TITLE: ${DUMBTERM_SITE_TITLE:-DumbTerm} 17 | # Optional PIN protection (leave empty to disable) 18 | DUMBTERM_PIN: ${DUMBTERM_PIN:-1234} 19 | # The base URL for the application 20 | BASE_URL: ${DUMBTERM_BASE_URL:-http://localhost:3000} # Use ALLOWED_ORIGINS below to restrict cors to specific origins 21 | ENABLE_STARSHIP: ${ENABLE_STARSHIP:-true} # Enable starship prompt 22 | LOCKOUT_TIME: ${DUMBTERM_LOCKOUT_TIME:-15} # Minutes 23 | MAX_SESSION_AGE: ${DUMBTERM_MAX_SESSION_AGE:-24} # Hours 24 | 25 | # (OPTIONAL) 26 | # Usage: Comma-separated list of urls: http://localhost:port,http://internalip:port,https://base.proxy.tld,https://authprovider.domain.tld 27 | # ALLOWED_ORIGINS: ${DUMBTERM_ALLOWED_ORIGINS:-http://localhost:3000} # Comment out to allow all origins (*) -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to remove and Starship 4 | remove_customizations() { 5 | # Remove Starship binary if it exists 6 | if [ -f "/usr/local/bin/starship" ]; then 7 | echo "Removing Starship..." 8 | rm /usr/local/bin/starship 9 | fi 10 | 11 | # # Remove Starship config 12 | # if [ -f "/root/.config/starship.toml" ]; then 13 | # rm /root/.config/starship.toml 14 | # fi 15 | # if [ -d "/root/.config/starship" ]; then 16 | # rm -rf /root/.config/starship 17 | # fi 18 | } 19 | 20 | # Always remove any existing Starship initialization first 21 | sed -i '/eval "$(starship init bash)"/d' /root/.bashrc 22 | 23 | # Handle Starship and Nerd Fonts based on ENABLE_STARSHIP env var 24 | if [ "$ENABLE_STARSHIP" = "true" ]; then 25 | # Create config directory and copy starship config if it exists 26 | mkdir -p /root/.config 27 | if [ -f "/app/config/starship.toml" ]; then 28 | cp /app/config/starship.toml /root/.config/starship.toml 29 | fi 30 | 31 | # Initialize Starship in .bashrc 32 | echo 'eval "$(starship init bash)"' >> /root/.bashrc 33 | echo "Starship initialization completed!" 34 | else 35 | remove_customizations 36 | echo "Starship and customizations have been disabled and removed" 37 | fi 38 | 39 | # Execute the passed command from the app directory 40 | cd /app 41 | exec "$@" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-trixie-slim 2 | 3 | # Install additional terminal utilities and prerequisites 4 | RUN apt-get update && apt-get upgrade -y && apt-get install -y --fix-missing \ 5 | apt-utils \ 6 | curl \ 7 | wget \ 8 | ssh \ 9 | git \ 10 | vim \ 11 | nano \ 12 | htop \ 13 | net-tools \ 14 | iputils-ping \ 15 | fontconfig \ 16 | unzip \ 17 | locales \ 18 | traceroute \ 19 | build-essential \ 20 | python3 \ 21 | && rm -rf /var/lib/apt/lists/* 22 | 23 | # Configure locales 24 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ 25 | locale-gen en_US.UTF-8 && \ 26 | update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 27 | 28 | # Set environment variables for locale 29 | ENV LANG=en_US.UTF-8 \ 30 | LANGUAGE=en_US:en \ 31 | LC_ALL=en_US.UTF-8 \ 32 | SHELL=/bin/bash 33 | 34 | # Install Starship 35 | RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes 36 | WORKDIR /app 37 | 38 | # Copy package files first for better layer caching 39 | COPY package*.json ./ 40 | 41 | # Install dependencies 42 | RUN npm ci --production && \ 43 | npm cache clean --force 44 | 45 | # Copy entrypoint script and set permissions 46 | COPY entrypoint.sh /entrypoint.sh 47 | RUN chmod +x /entrypoint.sh 48 | 49 | # Create data directory 50 | RUN mkdir -p data 51 | 52 | # Copy application files 53 | COPY . . 54 | 55 | # Build node-pty and copy xterm files 56 | RUN npm run copy-xterm 57 | 58 | # Expose port 59 | EXPOSE 3000 60 | 61 | # Start the application 62 | ENTRYPOINT ["/entrypoint.sh"] 63 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /config/starship.toml: -------------------------------------------------------------------------------- 1 | format = """ 2 | [░▒▓](#a3aed2)\ 3 | [ $os ](bg:#a3aed2 fg:#090c0c)\ 4 | [](bg:#769ff0 fg:#a3aed2)\ 5 | $directory\ 6 | [](fg:#769ff0 bg:#394260)\ 7 | $git_branch\ 8 | $git_status\ 9 | [](fg:#394260 bg:#212736)\ 10 | $nodejs\ 11 | $rust\ 12 | $golang\ 13 | $php\ 14 | [](fg:#212736 bg:#1d2230)\ 15 | $time\ 16 | [ ](fg:#1d2230)\ 17 | \n$character""" 18 | 19 | [os] 20 | format = '$symbol' 21 | disabled = false 22 | 23 | [os.symbols] 24 | Alpine = "" 25 | Arch = "" 26 | CentOS = "" 27 | Debian = "" 28 | Fedora = "" 29 | Gentoo = "" 30 | Linux = "" 31 | Macos = "" 32 | Manjaro = " " 33 | Mint = "" 34 | NixOS = "" 35 | Raspbian = "" 36 | Ubuntu = "" 37 | Windows = " " 38 | 39 | [directory] 40 | style = "fg:#e3e5e5 bg:#769ff0" 41 | format = "[ $path ]($style)" 42 | truncation_length = 3 43 | truncation_symbol = "…/" 44 | 45 | [directory.substitutions] 46 | "Documents" = "󰈙 " 47 | "Downloads" = " " 48 | "Music" = " " 49 | "Pictures" = " " 50 | 51 | [git_branch] 52 | symbol = "" 53 | style = "bg:#394260" 54 | format = '[[ $symbol $branch ](fg:#769ff0 bg:#394260)]($style)' 55 | 56 | [git_status] 57 | style = "bg:#394260" 58 | format = '[[($all_status$ahead_behind )](fg:#769ff0 bg:#394260)]($style)' 59 | 60 | [nodejs] 61 | symbol = "" 62 | style = "bg:#212736" 63 | format = '[[ $symbol ($version) ](fg:#769ff0 bg:#212736)]($style)' 64 | 65 | [rust] 66 | symbol = "" 67 | style = "bg:#212736" 68 | format = '[[ $symbol ($version) ](fg:#769ff0 bg:#212736)]($style)' 69 | 70 | [golang] 71 | symbol = "" 72 | style = "bg:#212736" 73 | format = '[[ $symbol ($version) ](fg:#769ff0 bg:#212736)]($style)' 74 | 75 | [php] 76 | symbol = "" 77 | style = "bg:#212736" 78 | format = '[[ $symbol ($version) ](fg:#769ff0 bg:#212736)]($style)' 79 | 80 | [time] 81 | disabled = false 82 | time_format = "%I:%M %p" # Hour:Minute Format 83 | style = "bg:#1d2230" 84 | format = '[[  $time ](fg:#a0a9cb bg:#1d2230)]($style)' -------------------------------------------------------------------------------- /scripts/pwa-manifest-generator.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const PUBLIC_DIR = path.join(__dirname, "..", "public"); 4 | const ASSETS_DIR = path.join(PUBLIC_DIR, "assets"); 5 | const BASE_PATH = process.env.BASE_URL ? new URL(process.env.BASE_URL).pathname.replace(/\/$/, '') : ''; 6 | 7 | function getFiles(dir, basePath = "/") { 8 | let fileList = []; 9 | const files = fs.readdirSync(dir); 10 | const excludeList = [".DS_Store"]; // Add files or patterns to exclude here 11 | 12 | files.forEach((file) => { 13 | const filePath = path.join(dir, file); 14 | const fileUrl = path.join(basePath, file).replace(/\\/g, "/"); 15 | 16 | if (fs.statSync(filePath).isDirectory()) { 17 | fileList = fileList.concat(getFiles(filePath, fileUrl)); 18 | } else { 19 | if (!excludeList.includes(file)){ 20 | fileList.push(fileUrl); 21 | } 22 | } 23 | }); 24 | 25 | return fileList; 26 | } 27 | 28 | function generateAssetManifest() { 29 | console.log("Generating Asset manifest..."); 30 | const assets = getFiles(PUBLIC_DIR); 31 | fs.writeFileSync(path.join(ASSETS_DIR, "asset-manifest.json"), JSON.stringify(assets, null, 2)); 32 | console.log("Asset manifest generated!"); 33 | } 34 | 35 | function generatePWAManifest(siteTitle) { 36 | generateAssetManifest(); // fetched later in service-worker 37 | 38 | const pwaManifest = { 39 | name: siteTitle, 40 | short_name: siteTitle, 41 | description: "A stupidly simple web-based terminal emulator", 42 | start_url: BASE_PATH || "/", 43 | scope: BASE_PATH || "/", 44 | display: "standalone", 45 | background_color: "#ffffff", 46 | theme_color: "#000000", 47 | icons: [ 48 | { 49 | src: `${BASE_PATH}/assets/dumbterm.png`, 50 | type: "image/png", 51 | sizes: "192x192" 52 | }, 53 | { 54 | src: `${BASE_PATH}/assets/dumbterm.png`, 55 | type: "image/png", 56 | sizes: "512x512" 57 | } 58 | ], 59 | orientation: "any" 60 | }; 61 | 62 | fs.writeFileSync(path.join(ASSETS_DIR, "manifest.json"), JSON.stringify(pwaManifest, null, 2)); 63 | console.log("PWA manifest generated!"); 64 | } 65 | 66 | module.exports = { generatePWAManifest }; -------------------------------------------------------------------------------- /public/assets/dumbterm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 14 | 20 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /scripts/copy-xterm.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | // Define source and destination paths 7 | const nodeModulesPath = path.join(__dirname, '..', 'node_modules', '@xterm'); 8 | const publicNodeModulesPath = path.join(__dirname, '..', 'public', 'node_modules', '@xterm'); 9 | 10 | // Function to create directory recursively if it doesn't exist 11 | function ensureDirExists(dirPath) { 12 | if (!fs.existsSync(dirPath)) { 13 | fs.mkdirSync(dirPath, { recursive: true }); 14 | console.log(`Created directory: ${dirPath}`); 15 | } 16 | } 17 | 18 | // Function to copy folder with all its content 19 | function copyFolder(source, destination) { 20 | // Create destination directory if it doesn't exist 21 | ensureDirExists(destination); 22 | 23 | // Read all files/folders from source 24 | const items = fs.readdirSync(source); 25 | 26 | items.forEach(item => { 27 | const sourcePath = path.join(source, item); 28 | const destPath = path.join(destination, item); 29 | 30 | // Check if item is directory or file 31 | const stats = fs.statSync(sourcePath); 32 | 33 | if (stats.isDirectory()) { 34 | // Recursively copy subfolders 35 | copyFolder(sourcePath, destPath); 36 | } else { 37 | // Copy the file 38 | fs.copyFileSync(sourcePath, destPath); 39 | } 40 | }); 41 | 42 | // console.log(`Copied from ${source} to ${destination}`); 43 | } 44 | 45 | // Main function to copy xterm files 46 | function copyXtermFiles() { 47 | console.log('Starting to copy xterm files...'); 48 | 49 | try { 50 | // Ensure the destination directory exists 51 | ensureDirExists(path.join(__dirname, '..', 'public', 'node_modules')); 52 | ensureDirExists(publicNodeModulesPath); 53 | 54 | // List all directories in the @xterm folder 55 | const xtermDirs = fs.readdirSync(nodeModulesPath); 56 | 57 | // Copy xterm main module 58 | if (xtermDirs.includes('xterm')) { 59 | copyFolder( 60 | path.join(nodeModulesPath, 'xterm'), 61 | path.join(publicNodeModulesPath, 'xterm') 62 | ); 63 | } 64 | 65 | // Copy all addon directories (matching addon-*) 66 | xtermDirs.forEach(dir => { 67 | if (dir.startsWith('addon-')) { 68 | copyFolder( 69 | path.join(nodeModulesPath, dir), 70 | path.join(publicNodeModulesPath, dir) 71 | ); 72 | } 73 | }); 74 | 75 | console.log('Successfully copied all xterm files'); 76 | } catch (error) { 77 | console.error('Error copying xterm files:', error); 78 | process.exit(1); 79 | } 80 | } 81 | 82 | // Execute the copy function 83 | copyXtermFiles(); -------------------------------------------------------------------------------- /scripts/cors.js: -------------------------------------------------------------------------------- 1 | const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'; 2 | const NODE_ENV = process.env.NODE_ENV || 'production'; 3 | let allowedOrigins = []; 4 | 5 | function setupOrigins(baseUrl) { 6 | allowedOrigins = [ baseUrl ]; 7 | 8 | if (NODE_ENV === 'development' || ALLOWED_ORIGINS === '*') allowedOrigins = '*'; 9 | else if (ALLOWED_ORIGINS && typeof ALLOWED_ORIGINS === 'string') { 10 | try { 11 | const allowed = ALLOWED_ORIGINS.split(',').map(origin => origin.trim()); 12 | allowed.forEach(origin => { 13 | const normalizedOrigin = normalizeOrigin(origin); 14 | if (normalizedOrigin !== baseUrl) allowedOrigins.push(normalizedOrigin); 15 | }); 16 | } 17 | catch (error) { 18 | console.error(`Error setting up ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}:`, error); 19 | } 20 | } 21 | console.log("ALLOWED ORIGINS:", allowedOrigins); 22 | return allowedOrigins; 23 | } 24 | 25 | function normalizeOrigin(origin) { 26 | if (origin) { 27 | try { 28 | const normalizedOrigin = new URL(origin).origin; 29 | return normalizedOrigin; 30 | } catch (error) { 31 | console.error("Error parsing referer URL:", error); 32 | throw new Error("Error parsing referer URL:", error); 33 | } 34 | } 35 | } 36 | 37 | function validateOrigin(origin) { 38 | if (NODE_ENV === 'development' || allowedOrigins === '*') return true; 39 | 40 | try { 41 | if (origin) origin = normalizeOrigin(origin); 42 | else { 43 | console.warn("No origin to validate."); 44 | return false; 45 | } 46 | 47 | console.log("Validating Origin:", origin); 48 | 49 | if (allowedOrigins.includes(origin)) { 50 | console.log("Allowed request from origin:", origin); 51 | return true; 52 | } 53 | else { 54 | console.warn("Blocked request from origin:", origin); 55 | return false; 56 | } 57 | } 58 | catch (error) { 59 | console.error(error); 60 | } 61 | } 62 | 63 | function originValidationMiddleware(req, res, next) { 64 | const origin = req.headers.referer || `${req.protocol}://${req.headers.host}`; 65 | const isOriginValid = validateOrigin(origin); 66 | 67 | if (isOriginValid) { 68 | next(); 69 | } else { 70 | res.status(403).json({ error: 'Forbidden' }); 71 | } 72 | } 73 | 74 | 75 | function getCorsOptions(baseUrl) { 76 | const allowedOrigins = setupOrigins(baseUrl); 77 | const corsOptions = { 78 | origin: allowedOrigins, 79 | credentials: true, 80 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 81 | allowedHeaders: ['Content-Type', 'Authorization'], 82 | }; 83 | 84 | return corsOptions; 85 | } 86 | 87 | module.exports = { getCorsOptions, originValidationMiddleware, validateOrigin }; -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DumbTerm 7 | 8 | 9 | 10 | 20 | 21 | 22 |
23 |
24 | 27 |
28 |
29 |
30 | 48 |
49 |

DumbTerm

50 |

Enter PIN

51 |
52 | 53 |
54 |
55 |
56 | Built by DumbWare 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Cursor IDE 133 | .cursor 134 | .cursor/ 135 | 136 | # OS files 137 | .DS_Store 138 | Thumbs.db 139 | 140 | # Data folder for the data volume mapping 141 | data/ 142 | 143 | # Generated PWA Files 144 | /public/*manifest.json 145 | /public/assets/*manifest.json 146 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger the workflow on pushes to the main branch 7 | 8 | env: 9 | DOCKER_IMAGE: dumbwareio/dumbterm 10 | PLATFORMS: linux/amd64,linux/arm64 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | with: 24 | platforms: 'arm64,amd64' 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | with: 29 | driver-opts: | 30 | image=moby/buildkit:latest 31 | network=host 32 | 33 | - name: Cache npm dependencies 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.npm 37 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-node- 40 | 41 | - name: Log in to Docker Hub 42 | uses: docker/login-action@v3 43 | with: 44 | username: ${{ secrets.DOCKER_USERNAME }} 45 | password: ${{ secrets.DOCKER_PASSWORD }} 46 | 47 | - name: Extract version from package.json 48 | id: package_version 49 | run: | 50 | VERSION=$(node -p "require('./package.json').version") 51 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 52 | echo "Extracted version: $VERSION" 53 | 54 | - name: Set Docker tags 55 | id: docker_meta 56 | run: | 57 | # Always add the version tag 58 | echo "VERSION_TAG=${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}" >> $GITHUB_OUTPUT 59 | 60 | # Add branch-specific tags 61 | if [ "${{ github.ref }}" = "refs/heads/main" ]; then 62 | echo "ADDITIONAL_TAG=${{ env.DOCKER_IMAGE }}:latest" >> $GITHUB_OUTPUT 63 | echo "Using tags: ${{ steps.package_version.outputs.VERSION_TAG }}, ${{ env.DOCKER_IMAGE }}:latest" 64 | elif [ "${{ github.ref }}" = "refs/heads/testing" ]; then 65 | echo "ADDITIONAL_TAG=${{ env.DOCKER_IMAGE }}:testing" >> $GITHUB_OUTPUT 66 | echo "Using tags: ${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}, ${{ env.DOCKER_IMAGE }}:testing" 67 | else 68 | echo "Using tag: ${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}" 69 | fi 70 | 71 | - name: Build and Push Multi-Platform Image 72 | uses: docker/build-push-action@v5 73 | with: 74 | context: . 75 | platforms: ${{ env.PLATFORMS }} 76 | push: true 77 | tags: | 78 | ${{ steps.docker_meta.outputs.VERSION_TAG}} 79 | ${{ steps.docker_meta.outputs.ADDITIONAL_TAG }} 80 | cache-from: type=gha 81 | cache-to: type=gha,mode=max 82 | provenance: false 83 | build-args: | 84 | BUILDKIT_INLINE_CACHE=1 -------------------------------------------------------------------------------- /public/managers/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * StorageManager - A class to abstract localStorage operations 3 | * Provides a generic interface for storing and retrieving data 4 | */ 5 | export default class StorageManager { 6 | /** 7 | * Constructor for StorageManager 8 | * @param {string} prefix - Optional prefix for all storage keys 9 | */ 10 | constructor(prefix = 'dumbterm-') { 11 | this.prefix = prefix; 12 | this.isAvailable = this._checkAvailability(); 13 | } 14 | 15 | /** 16 | * Check if localStorage is available 17 | * @returns {boolean} True if localStorage is available 18 | */ 19 | _checkAvailability() { 20 | try { 21 | const testKey = '__storage_test__'; 22 | localStorage.setItem(testKey, testKey); 23 | localStorage.removeItem(testKey); 24 | return true; 25 | } catch (e) { 26 | console.warn('localStorage is not available:', e); 27 | return false; 28 | } 29 | } 30 | 31 | /** 32 | * Generate a prefixed key 33 | * @param {string} key - The key to prefix 34 | * @returns {string} The prefixed key 35 | */ 36 | _getKey(key) { 37 | return `${this.prefix}${key}`; 38 | } 39 | 40 | /** 41 | * Set a value in storage 42 | * @param {string} key - The key to store under 43 | * @param {any} value - The value to store (will be JSON serialized) 44 | * @returns {boolean} True if storage was successful 45 | */ 46 | set(key, value) { 47 | if (!this.isAvailable) return false; 48 | 49 | try { 50 | const serialized = JSON.stringify(value); 51 | localStorage.setItem(this._getKey(key), serialized); 52 | return true; 53 | } catch (e) { 54 | console.error('Failed to save to localStorage:', e); 55 | return false; 56 | } 57 | } 58 | 59 | /** 60 | * Get a value from storage 61 | * @param {string} key - The key to retrieve 62 | * @param {any} defaultValue - Default value if key doesn't exist 63 | * @returns {any} The retrieved value (JSON parsed) or defaultValue 64 | */ 65 | get(key, defaultValue = null) { 66 | if (!this.isAvailable) return defaultValue; 67 | 68 | try { 69 | const value = localStorage.getItem(this._getKey(key)); 70 | if (value === null) return defaultValue; 71 | return JSON.parse(value); 72 | } catch (e) { 73 | console.error('Failed to get from localStorage:', e); 74 | return defaultValue; 75 | } 76 | } 77 | 78 | /** 79 | * Remove a key from storage 80 | * @param {string} key - The key to remove 81 | * @returns {boolean} True if removal was successful 82 | */ 83 | remove(key) { 84 | if (!this.isAvailable) return false; 85 | 86 | try { 87 | localStorage.removeItem(this._getKey(key)); 88 | return true; 89 | } catch (e) { 90 | console.error('Failed to remove from localStorage:', e); 91 | return false; 92 | } 93 | } 94 | 95 | /** 96 | * Clear all items with this prefix 97 | * @returns {boolean} True if clearing was successful 98 | */ 99 | clear() { 100 | if (!this.isAvailable) return false; 101 | 102 | try { 103 | Object.keys(localStorage) 104 | .filter(key => key.startsWith(this.prefix)) 105 | .forEach(key => localStorage.removeItem(key)); 106 | return true; 107 | } catch (e) { 108 | console.error('Failed to clear localStorage:', e); 109 | return false; 110 | } 111 | } 112 | 113 | /** 114 | * Get all keys with this prefix 115 | * @returns {string[]} Array of keys (without prefix) 116 | */ 117 | keys() { 118 | if (!this.isAvailable) return []; 119 | 120 | try { 121 | return Object.keys(localStorage) 122 | .filter(key => key.startsWith(this.prefix)) 123 | .map(key => key.slice(this.prefix.length)); 124 | } catch (e) { 125 | console.error('Failed to get keys from localStorage:', e); 126 | return []; 127 | } 128 | } 129 | 130 | /** 131 | * Check if a key exists 132 | * @param {string} key - The key to check 133 | * @returns {boolean} True if the key exists 134 | */ 135 | has(key) { 136 | if (!this.isAvailable) return false; 137 | return localStorage.getItem(this._getKey(key)) !== null; 138 | } 139 | } -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | /** 2 | * Cursor rules for maintaining code quality and consistency 3 | */ 4 | 5 | { 6 | "rules": { 7 | "file-header-docs": { 8 | "description": "All source files must have a header comment explaining their purpose", 9 | "pattern": "src/**/*.js", 10 | "check": { 11 | "type": "regex", 12 | "value": "^/\\*\\*\\n \\* [^\\n]+\\n \\* [^\\n]+\\n \\* [^\\n]+\\n \\*/\\n", 13 | "message": "File must start with a header comment block (3 lines) explaining its purpose" 14 | } 15 | } 16 | } 17 | } 18 | 19 | # Project Principles 20 | 21 | # Code Philosophy 22 | - Keep code simple, smart, and follow best practices 23 | - Don't over-engineer for the sake of engineering 24 | - Use standard conventions and patterns 25 | - Write human-readable code 26 | - Keep it simple so the app just works 27 | - Follow the principle: "Make it work, make it right, make it fast" 28 | - Comments should explain "why" behind the code in more complex functions 29 | - Overcommented code is better than undercommented code 30 | 31 | # Commit Conventions 32 | - Use Conventional Commits format: 33 | - feat: new features 34 | - fix: bug fixes 35 | - docs: documentation changes 36 | - style: formatting, missing semi colons, etc. 37 | - refactor: code changes that neither fix bugs nor add features 38 | - test: adding or modifying tests 39 | - chore: updating build tasks, package manager configs, etc. 40 | - Each commit should be atomic and focused 41 | - Write clear, descriptive commit messages 42 | 43 | # Project Structure 44 | 45 | # Root Directory 46 | - Keep root directory clean with only essential files 47 | - Production configuration files in root: 48 | - docker-compose.yml 49 | - Dockerfile 50 | - .env.example 51 | - package.json 52 | - README.md 53 | 54 | # Source Code (/src) 55 | - All application source code in /src directory 56 | - app.js: Application setup and configuration 57 | - server.js: Server entry point 58 | - routes/: Route handlers 59 | - middleware/: Custom middleware 60 | - utils/: Helper functions and utilities 61 | - models/: Data models (if applicable) 62 | - services/: Business logic 63 | 64 | # Development 65 | - All development configurations in /dev directory 66 | - Development specific files: 67 | - /dev/docker-compose.dev.yml 68 | - /dev/.env.dev.example 69 | - /dev/README.md (development setup instructions) 70 | 71 | # Static Assets and Uploads 72 | - Static assets in /public directory 73 | - Upload directories: 74 | - /uploads (production) 75 | - /local_uploads (local development) 76 | 77 | # Documentation 78 | - Main README.md in root focuses on production deployment 79 | - Development documentation in /dev/README.md 80 | - Code must be self-documenting with clear naming 81 | - Complex logic must include comments explaining "why" not "what" 82 | - JSDoc comments for public functions and APIs 83 | 84 | # Docker Configuration 85 | - Use environment-specific .dockerignore files: 86 | - .dockerignore: Production defaults (most restrictive) 87 | - dev/.dockerignore: Development-specific (allows test/dev files) 88 | - Production .dockerignore should exclude: 89 | - All test files and configurations 90 | - Development-only dependencies 91 | - Documentation and non-essential files 92 | - Local development configurations 93 | - Development .dockerignore should: 94 | - Allow test files and configurations 95 | - Allow development dependencies 96 | - Still exclude node_modules and sensitive files 97 | - Keep Docker-specific files excluded 98 | - Docker Compose configurations: 99 | - Production: docker-compose.yml in root 100 | - Development: docker-compose.dev.yml in /dev 101 | - Use BuildKit features when needed 102 | - Document any special build arguments 103 | - Multi-stage builds: 104 | - Use appropriate base images 105 | - Minimize final image size 106 | - Separate development and production stages 107 | - Use specific version tags for base images 108 | 109 | # Code Style 110 | - Follow ESLint and Prettier configurations 111 | - Use meaningful variable and function names 112 | - Keep functions small and focused 113 | - Maximum line length: 100 characters 114 | - Use modern JavaScript features appropriately 115 | - Prefer clarity over cleverness 116 | - Add logging when appropriate and environment variable DEBUG is set to true 117 | 118 | # Code Organization and Modularity 119 | - Break complex functionality into separate modules 120 | - Each module should have a single responsibility 121 | - Modules should be self-contained with clear interfaces 122 | - Avoid circular dependencies between modules 123 | - Keep module files under 300 lines when possible 124 | - Export only what is necessary from each module 125 | - Group related functionality in the same directory 126 | 127 | # Theme and Styling 128 | - Maintain consistent theme colors across the application: 129 | - Light theme colors: 130 | - Background: #ffffff 131 | - Text: #1a1a1a 132 | - Primary: #2563eb 133 | - Secondary: #64748b 134 | - Dark theme colors: 135 | - Background: #1a1a1a 136 | - Text: #ffffff 137 | - Primary: #3b82f6 138 | - Secondary: #94a3b8 139 | - Theme toggle must be present on all pages 140 | - Theme preference must persist in localStorage 141 | - System theme preference should be respected by default 142 | 143 | # Security and Authentication 144 | - PIN authentication logic in login.html must not be modified without verification and override from the owner 145 | - PIN input fields must: 146 | - Use type="password" 147 | - Have numeric validation 148 | - Support paste functionality 149 | - Auto-advance on input 150 | - Support backspace navigation 151 | - Maintain brute force protection with: 152 | - Maximum attempt limits 153 | - Lockout periods 154 | - Constant-time PIN comparison 155 | - Session management must use secure cookies 156 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DumbTerm 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | 45 |
46 |
47 |
48 | 55 | 62 | 80 |
81 |

DumbTerm

82 | 83 |
84 |
85 | 86 |
87 | 88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | Loading... 98 | Built by DumbWareio 99 |
100 | 101 | 102 | -------------------------------------------------------------------------------- /public/login.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | // Theme toggle functionality 3 | function initThemeToggle() { 4 | const themeToggle = document.getElementById('themeToggle'); 5 | 6 | themeToggle.addEventListener('click', () => { 7 | const currentTheme = document.documentElement.getAttribute('data-theme'); 8 | const newTheme = currentTheme === 'light' ? 'dark' : 'light'; 9 | 10 | document.documentElement.setAttribute('data-theme', newTheme); 11 | localStorage.setItem('theme', newTheme); 12 | }); 13 | } 14 | 15 | // Helper function to join paths with base path 16 | function joinPath(path) { 17 | const basePath = window.appConfig?.basePath || ''; 18 | // Remove any leading slash from path and trailing slash from basePath 19 | const cleanPath = path.replace(/^\/+/, ''); 20 | const cleanBase = basePath.replace(/\/+$/, ''); 21 | 22 | // Join with single slash 23 | return cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath; 24 | } 25 | 26 | // PIN input functionality 27 | async function setupPinInputs() { 28 | const form = document.getElementById('pinForm'); 29 | if (!form) return; // Only run on login page 30 | 31 | // Fetch PIN length from server 32 | fetch(joinPath('pin-length')) 33 | .then(response => response.json()) 34 | .then(data => { 35 | const pinLength = data.length; 36 | const container = document.querySelector('.pin-input-container'); 37 | 38 | // Create PIN input fields 39 | for (let i = 0; i < pinLength; i++) { 40 | const input = document.createElement('input'); 41 | input.type = 'password'; 42 | input.maxLength = 1; 43 | input.className = 'pin-input'; 44 | input.setAttribute('inputmode', 'numeric'); 45 | input.pattern = '[0-9]*'; 46 | input.setAttribute('autocomplete', 'off'); 47 | container.appendChild(input); 48 | } 49 | 50 | // Handle input behavior 51 | const inputs = container.querySelectorAll('.pin-input'); 52 | 53 | // Focus first input immediately 54 | if (inputs.length > 0) { 55 | inputs[0].focus(); 56 | } 57 | 58 | inputs.forEach((input, index) => { 59 | input.addEventListener('input', (e) => { 60 | // Only allow numbers 61 | e.target.value = e.target.value.replace(/[^0-9]/g, ''); 62 | 63 | if (e.target.value) { 64 | e.target.classList.add('has-value'); 65 | if (index < inputs.length - 1) { 66 | inputs[index + 1].focus(); 67 | } else { 68 | // Last digit entered, submit the form 69 | const pin = Array.from(inputs).map(input => input.value).join(''); 70 | submitPin(pin, inputs); 71 | } 72 | } else { 73 | e.target.classList.remove('has-value'); 74 | } 75 | }); 76 | 77 | input.addEventListener('keydown', (e) => { 78 | if (e.key === 'Backspace' && !e.target.value && index > 0) { 79 | inputs[index - 1].focus(); 80 | } 81 | }); 82 | 83 | // Prevent paste of multiple characters 84 | input.addEventListener('paste', (e) => { 85 | e.preventDefault(); 86 | const pastedData = e.clipboardData.getData('text'); 87 | const numbers = pastedData.match(/\d/g); 88 | 89 | if (numbers) { 90 | numbers.forEach((num, i) => { 91 | if (inputs[index + i]) { 92 | inputs[index + i].value = num; 93 | inputs[index + i].classList.add('has-value'); 94 | if (index + i + 1 < inputs.length) { 95 | inputs[index + i + 1].focus(); 96 | } else { 97 | // If paste fills all inputs, submit the form 98 | const pin = Array.from(inputs).map(input => input.value).join(''); 99 | submitPin(pin, inputs); 100 | } 101 | } 102 | }); 103 | } 104 | }); 105 | }); 106 | }); 107 | } 108 | 109 | // Handle PIN submission with security features 110 | function submitPin(pin, inputs) { 111 | const errorElement = document.querySelector('.pin-error'); 112 | 113 | fetch(joinPath('verify-pin'), { 114 | method: 'POST', 115 | headers: { 116 | 'Content-Type': 'application/json', 117 | }, 118 | body: JSON.stringify({ pin }), 119 | credentials: 'same-origin', // Ensure cookies are sent 120 | redirect: 'follow' // Follow server redirects 121 | }) 122 | .then(async response => { 123 | // If redirected, the response will be a redirect status (3xx) 124 | if (response.redirected) { 125 | window.location.replace(response.url); 126 | return; 127 | } 128 | 129 | const data = await response.json(); 130 | 131 | if (response.status === 429) { 132 | // Handle lockout 133 | errorElement.textContent = data.error; 134 | errorElement.setAttribute('aria-hidden', 'false'); 135 | inputs.forEach(input => { 136 | input.value = ''; 137 | input.classList.remove('has-value'); 138 | input.disabled = true; 139 | }); 140 | } else { 141 | // Handle invalid PIN 142 | const message = data.attemptsLeft > 0 143 | ? `Incorrect PIN. ${data.attemptsLeft} attempts remaining.` 144 | : 'Incorrect PIN. Last attempt before lockout.'; 145 | 146 | errorElement.textContent = message; 147 | errorElement.setAttribute('aria-hidden', 'false'); 148 | inputs.forEach(input => { 149 | input.value = ''; 150 | input.classList.remove('has-value'); 151 | }); 152 | inputs[0].focus(); 153 | } 154 | }) 155 | .catch(error => { 156 | console.error('Error:', error); 157 | errorElement.textContent = 'An error occurred. Please try again.'; 158 | errorElement.setAttribute('aria-hidden', 'false'); 159 | }); 160 | } 161 | 162 | async function initialize() { 163 | // Set site title 164 | const siteTitle = window.appConfig?.siteTitle || 'DumbTerm'; 165 | document.getElementById('pageTitle').textContent = siteTitle; 166 | document.getElementById('siteTitle').textContent = siteTitle; 167 | // Show demo banner if in demo mode 168 | if (window.appConfig?.isDemoMode) { 169 | document.getElementById('demo-banner').style.display = 'block'; 170 | } 171 | initThemeToggle(); 172 | setupPinInputs(); 173 | } 174 | 175 | initialize(); 176 | }); -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | import TerminalManager from "./managers/terminal.js"; 2 | import ServiceWorkerManager from "./managers/serviceWorker.js"; 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | let serviceWorkerManager; 6 | 7 | async function waitForFonts() { 8 | // Create a promise that resolves when fonts are loaded 9 | const fontPromises = [ 10 | 'FiraCode Nerd Font', 11 | ].map(font => document.fonts.load(`1em "${font}"`)); 12 | 13 | try { 14 | await Promise.all(fontPromises); 15 | } catch (e) { 16 | console.warn('Font loading error:', e); 17 | } 18 | } 19 | 20 | // Add logout functionality 21 | const logoutBtn = document.getElementById('logoutBtn'); 22 | if (logoutBtn) { 23 | logoutBtn.addEventListener('click', () => { 24 | fetch(joinPath('logout'), { 25 | method: 'POST', 26 | credentials: 'same-origin' 27 | }) 28 | .then(response => response.json()) 29 | .then(data => { 30 | if (data.success) { 31 | window.location.reload(); 32 | } 33 | }) 34 | .catch(error => { 35 | console.error('Logout failed:', error); 36 | }); 37 | }); 38 | } 39 | 40 | // Theme toggle functionality 41 | function initThemeToggle() { 42 | const themeToggle = document.getElementById('themeToggle'); 43 | 44 | themeToggle.addEventListener('click', () => { 45 | const currentTheme = document.documentElement.getAttribute('data-theme'); 46 | const newTheme = currentTheme === 'light' ? 'dark' : 'light'; 47 | 48 | document.documentElement.setAttribute('data-theme', newTheme); 49 | localStorage.setItem('theme', newTheme); 50 | }); 51 | } 52 | 53 | // Helper function to join paths with base path 54 | function joinPath(path) { 55 | const basePath = window.appConfig?.basePath || ''; 56 | // Remove any leading slash from path and trailing slash from basePath 57 | const cleanPath = path.replace(/^\/+/, ''); 58 | const cleanBase = basePath.replace(/\/+$/, ''); 59 | 60 | // Join with single slash 61 | return cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath; 62 | } 63 | 64 | const detectOS = () => { 65 | const userAgent = navigator.userAgent; 66 | const isMac = /Macintosh|Mac OS X/i.test(userAgent); 67 | return isMac; 68 | } 69 | const isMacOS = detectOS(); 70 | 71 | const setupToolTips = (tooltips) => { 72 | // Check if it's a mobile device using a media query or pointer query 73 | const isMobile = window.matchMedia('(max-width: 585px)').matches || window.matchMedia('(pointer: coarse)').matches; 74 | if (isMobile) return; 75 | 76 | // Function to hide all visible tooltips 77 | const hideAllTooltips = () => { 78 | document.querySelectorAll('.tooltip.show').forEach(tip => { 79 | tip.classList.remove('show'); 80 | }); 81 | }; 82 | 83 | tooltips.forEach((element) => { 84 | let tooltipText = element.getAttribute('data-tooltip'); 85 | const shortcutsStr = element.getAttribute('data-shortcuts'); 86 | 87 | if (tooltipText && shortcutsStr) { 88 | try { 89 | const shortcuts = JSON.parse(shortcutsStr); 90 | let shortcutToUse = isMacOS ? shortcuts.mac : shortcuts.win; 91 | 92 | if (shortcutToUse) { 93 | tooltipText = tooltipText.replace(`{shortcut}`, shortcutToUse); 94 | element.setAttribute('data-tooltip', tooltipText); 95 | } else { 96 | console.warn(`No shortcut found for ${isMacOS ? 'mac' : 'win'}`); 97 | } 98 | 99 | } catch (error) { 100 | console.error("Error parsing shortcuts:", error); 101 | } 102 | } 103 | 104 | let tooltip = document.createElement('div'); 105 | tooltip.classList.add('tooltip'); 106 | document.body.appendChild(tooltip); 107 | 108 | element.addEventListener('mouseover', (e) => { 109 | // First hide all visible tooltips 110 | hideAllTooltips(); 111 | 112 | // Then show this tooltip 113 | tooltip.textContent = element.getAttribute('data-tooltip'); 114 | tooltip.style.left = e.pageX + 10 + 'px'; 115 | tooltip.style.top = e.pageY + 10 + 'px'; 116 | tooltip.classList.add('show'); 117 | 118 | // Stop event propagation to prevent parent tooltips from showing 119 | e.stopPropagation(); 120 | }); 121 | 122 | element.addEventListener('mouseout', (e) => { 123 | tooltip.classList.remove('show'); 124 | 125 | // Prevent the mouseout event from bubbling to parent elements 126 | e.stopPropagation(); 127 | }); 128 | }); 129 | 130 | // Also hide tooltips when clicking anywhere 131 | document.addEventListener('click', hideAllTooltips); 132 | } 133 | 134 | /** 135 | * Updates the UI version display with a specific version 136 | * @param {string} version - Version to display (optional) 137 | * @param {boolean} loading - Whether to show loading state 138 | */ 139 | function updateVersionDisplay(version, loading = false) { 140 | const versionDisplay = document.getElementById('version-display'); 141 | if (!versionDisplay) return; 142 | 143 | if (loading) { 144 | versionDisplay.textContent = 'Loading...'; 145 | versionDisplay.classList.add('loading'); 146 | return; 147 | } 148 | 149 | if (version) { 150 | versionDisplay.textContent = `v${version}`; 151 | versionDisplay.classList.remove('loading'); 152 | return; 153 | } 154 | 155 | // No version provided, try to get from service worker manager or config 156 | if (serviceWorkerManager) { 157 | serviceWorkerManager.getCurrentCacheVersion() 158 | .then(cacheInfo => { 159 | if (cacheInfo.version) { 160 | versionDisplay.textContent = `v${cacheInfo.version}`; 161 | } else if (window.appConfig?.version) { 162 | versionDisplay.textContent = `v${window.appConfig.version}`; 163 | } else { 164 | versionDisplay.textContent = ''; 165 | } 166 | versionDisplay.classList.remove('loading'); 167 | }) 168 | .catch(error => { 169 | console.error('Error updating version display:', error); 170 | // Fall back to app config version 171 | if (window.appConfig?.version) { 172 | versionDisplay.textContent = `v${window.appConfig.version}`; 173 | } else { 174 | versionDisplay.textContent = ''; 175 | } 176 | versionDisplay.classList.remove('loading'); 177 | }); 178 | } else { 179 | // Fallback if service worker manager isn't initialized 180 | if (window.appConfig?.version) { 181 | versionDisplay.textContent = `v${window.appConfig.version}`; 182 | } else { 183 | versionDisplay.textContent = ''; 184 | } 185 | versionDisplay.classList.remove('loading'); 186 | } 187 | } 188 | 189 | /** 190 | * Initializes the application 191 | */ 192 | async function initialize() { 193 | // Initialize UI components 194 | initThemeToggle(); 195 | 196 | // Set site title 197 | const siteTitle = window.appConfig?.siteTitle || 'DumbTerm'; 198 | document.getElementById('pageTitle').textContent = siteTitle; 199 | document.getElementById('siteTitle').textContent = siteTitle; 200 | 201 | // Configure UI based on app settings 202 | if (window.appConfig?.isDemoMode) { 203 | document.getElementById('demo-banner').style.display = 'block'; 204 | } 205 | 206 | if (!window.appConfig?.isPinRequired) { 207 | document.getElementById("logoutBtn").style.display = 'none'; 208 | } 209 | 210 | // Wait for fonts to load 211 | await waitForFonts(); 212 | 213 | // Initialize terminal 214 | const terminalManager = new TerminalManager(isMacOS, setupToolTips); 215 | 216 | // Set up tooltips 217 | const tooltips = document.querySelectorAll('[data-tooltip]'); 218 | setupToolTips(tooltips); 219 | 220 | // Initialize service worker manager 221 | serviceWorkerManager = new ServiceWorkerManager(); 222 | serviceWorkerManager.initialize(updateVersionDisplay); 223 | } 224 | 225 | initialize().catch(console.error); 226 | }); -------------------------------------------------------------------------------- /scripts/demo/terminal.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | 3 | class DemoTerminal extends EventEmitter { 4 | constructor(options = {}) { 5 | super(); 6 | this.cols = options.cols || 80; 7 | this.rows = options.rows || 24; 8 | this.currentDirectory = '/home/demo'; 9 | this.commandBuffer = ''; 10 | this.pid = Math.floor(Math.random() * 10000); // Fake PID 11 | this.username = 'demo'; 12 | this.hostname = 'dumbterm'; 13 | this.osInfo = { 14 | sysname: 'Linux', 15 | release: '5.10.0', 16 | version: 'Demo', 17 | machine: 'x86_64' 18 | }; 19 | 20 | // Basic file system simulation 21 | this.fileSystem = { 22 | '/': { type: 'dir', contents: ['home', 'usr', 'etc'] }, 23 | '/home': { type: 'dir', contents: ['demo'] }, 24 | '/home/demo': { type: 'dir', contents: ['Documents', 'Downloads', '.bashrc'] }, 25 | '/home/demo/Documents': { type: 'dir', contents: ['readme.txt'] }, 26 | '/home/demo/Downloads': { type: 'dir', contents: [] }, 27 | '/home/demo/Documents/readme.txt': { type: 'file', contents: 'Welcome to DumbTerm Demo Mode!\nThis is a simulated terminal environment.\n' }, 28 | '/usr': { type: 'dir', contents: ['bin', 'local'] }, 29 | '/usr/bin': { type: 'dir', contents: ['ls', 'cd', 'pwd', 'cat', 'echo', 'whoami', 'hostname', 'uname', 'date'] }, 30 | '/etc': { type: 'dir', contents: ['passwd', 'hosts'] } 31 | }; 32 | 33 | // Emit initial prompt 34 | setImmediate(() => { 35 | this.emit('data', 'demo@dumbterm:' + this.currentDirectory + '$ '); 36 | }); 37 | } 38 | 39 | write(data) { 40 | // Handle terminal input 41 | if (data === '\r') { 42 | // Process command when Enter is pressed 43 | this.processCommand(this.commandBuffer.trim()); 44 | this.commandBuffer = ''; 45 | } else if (data === '\u007f') { 46 | // Handle backspace 47 | this.commandBuffer = this.commandBuffer.slice(0, -1); 48 | this.emit('data', '\b \b'); // Move back, clear character, move back 49 | } else { 50 | // Echo character and add to buffer 51 | this.commandBuffer += data; 52 | this.emit('data', data); 53 | } 54 | } 55 | 56 | resize(cols, rows) { 57 | this.cols = cols; 58 | this.rows = rows; 59 | } 60 | 61 | kill() { 62 | this.emit('exit', 0); 63 | } 64 | 65 | processCommand(command) { 66 | this.emit('data', '\r\n'); // New line after command 67 | 68 | if (!command) { 69 | this.emit('data', 'demo@dumbterm:' + this.currentDirectory + '$ '); 70 | return; 71 | } 72 | 73 | const parts = command.split(' '); 74 | const cmd = parts[0]; 75 | const args = parts.slice(1); 76 | 77 | switch (cmd) { 78 | case 'ls': 79 | this.handleLs(args); 80 | break; 81 | case 'cd': 82 | this.handleCd(args[0]); 83 | break; 84 | case 'pwd': 85 | this.handlePwd(); 86 | break; 87 | case 'whoami': 88 | this.handleWhoami(); 89 | break; 90 | case 'hostname': 91 | this.handleHostname(); 92 | break; 93 | case 'uname': 94 | this.handleUname(args); 95 | break; 96 | case 'date': 97 | this.handleDate(); 98 | break; 99 | case 'cat': 100 | this.handleCat(args[0]); 101 | break; 102 | case 'echo': 103 | this.handleEcho(args); 104 | break; 105 | case 'help': 106 | this.handleHelp(); 107 | break; 108 | case 'clear': 109 | this.handleClear(); 110 | break; 111 | default: 112 | if (cmd.startsWith('./')) { 113 | this.emit('data', `bash: ${cmd}: Permission denied\r\n`); 114 | } else { 115 | this.emit('data', `${cmd}: command not found\r\n`); 116 | } 117 | } 118 | 119 | this.emit('data', 'demo@dumbterm:' + this.currentDirectory + '$ '); 120 | } 121 | 122 | handleLs(args) { 123 | const path = args[0] || this.currentDirectory; 124 | const absolutePath = this.resolveAbsolutePath(path); 125 | 126 | if (!this.fileSystem[absolutePath]) { 127 | this.emit('data', `ls: cannot access '${path}': No such file or directory\r\n`); 128 | return; 129 | } 130 | 131 | if (this.fileSystem[absolutePath].type !== 'dir') { 132 | this.emit('data', `${path}\r\n`); 133 | return; 134 | } 135 | 136 | const contents = this.fileSystem[absolutePath].contents; 137 | this.emit('data', contents.join(' ') + '\r\n'); 138 | } 139 | 140 | handleCd(path) { 141 | if (!path || path === '~') { 142 | this.currentDirectory = '/home/demo'; 143 | return; 144 | } 145 | 146 | // Handle '..' for moving up directories 147 | if (path === '..' || path.startsWith('../')) { 148 | const levels = path.split('/').filter(p => p === '..').length; 149 | let newPath = this.currentDirectory; 150 | 151 | for (let i = 0; i < levels; i++) { 152 | // Don't go up if we're already at root 153 | if (newPath === '/') break; 154 | newPath = newPath.split('/').slice(0, -1).join('/') || '/'; 155 | } 156 | 157 | this.currentDirectory = newPath; 158 | return; 159 | } 160 | 161 | const absolutePath = this.resolveAbsolutePath(path); 162 | 163 | if (!this.fileSystem[absolutePath]) { 164 | this.emit('data', `cd: no such directory: ${path}\r\n`); 165 | return; 166 | } 167 | 168 | if (this.fileSystem[absolutePath].type !== 'dir') { 169 | this.emit('data', `cd: not a directory: ${path}\r\n`); 170 | return; 171 | } 172 | 173 | this.currentDirectory = absolutePath; 174 | } 175 | 176 | handlePwd() { 177 | this.emit('data', this.currentDirectory + '\r\n'); 178 | } 179 | 180 | handleCat(path) { 181 | if (!path) { 182 | this.emit('data', 'cat: missing operand\r\n'); 183 | return; 184 | } 185 | 186 | const absolutePath = this.resolveAbsolutePath(path); 187 | 188 | if (!this.fileSystem[absolutePath]) { 189 | this.emit('data', `cat: ${path}: No such file or directory\r\n`); 190 | return; 191 | } 192 | 193 | if (this.fileSystem[absolutePath].type !== 'file') { 194 | this.emit('data', `cat: ${path}: Is a directory\r\n`); 195 | return; 196 | } 197 | 198 | this.emit('data', this.fileSystem[absolutePath].contents + '\r\n'); 199 | } 200 | 201 | handleEcho(args) { 202 | this.emit('data', args.join(' ') + '\r\n'); 203 | } 204 | 205 | handleWhoami() { 206 | this.emit('data', this.username + '\r\n'); 207 | } 208 | 209 | handleHostname() { 210 | this.emit('data', this.hostname + '\r\n'); 211 | } 212 | 213 | handleUname(args) { 214 | if (args.includes('-a') || args.includes('--all')) { 215 | this.emit('data', `${this.osInfo.sysname} ${this.hostname} ${this.osInfo.release} ${this.osInfo.version} ${this.osInfo.machine}\r\n`); 216 | } else if (args.includes('-s') || args.includes('--kernel-name')) { 217 | this.emit('data', this.osInfo.sysname + '\r\n'); 218 | } else if (args.includes('-r') || args.includes('--kernel-release')) { 219 | this.emit('data', this.osInfo.release + '\r\n'); 220 | } else if (args.includes('-m') || args.includes('--machine')) { 221 | this.emit('data', this.osInfo.machine + '\r\n'); 222 | } else { 223 | this.emit('data', this.osInfo.sysname + '\r\n'); 224 | } 225 | } 226 | 227 | handleDate() { 228 | const date = new Date(); 229 | this.emit('data', date.toString() + '\r\n'); 230 | } 231 | 232 | handleHelp() { 233 | this.emit('data', 234 | 'Available commands:\r\n' + 235 | ' ls - List directory contents\r\n' + 236 | ' cd - Change directory\r\n' + 237 | ' pwd - Print working directory\r\n' + 238 | ' whoami - Print current user name\r\n' + 239 | ' hostname - Show system hostname\r\n' + 240 | ' uname - Print system information\r\n' + 241 | ' date - Display current time and date\r\n' + 242 | ' cat - Display file contents\r\n' + 243 | ' echo - Display a message\r\n' + 244 | ' clear - Clear the terminal screen\r\n' + 245 | ' help - Show this help message\r\n' 246 | ); 247 | } 248 | 249 | handleClear() { 250 | this.emit('data', '\x1b[2J\x1b[H'); 251 | } 252 | 253 | resolveAbsolutePath(path) { 254 | if (!path) return this.currentDirectory; 255 | if (path.startsWith('/')) return path; 256 | return this.currentDirectory + '/' + path; 257 | } 258 | } 259 | 260 | module.exports = { 261 | spawn: (file, args = [], options = {}) => { 262 | return new DemoTerminal(options); 263 | } 264 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DumbTerm 2 | 3 | A stupidly simple web-based terminal emulator, with common tools and Starship enabled! 🚀 4 | 5 | ![dumbterm-preview](https://github.com/user-attachments/assets/45a4542f-7f69-4dcd-a6df-39e5231e3db2) 6 | 7 | - Access your terminal from any device with a web browser 8 | - Execute commands just like in a native terminal 9 | - Starship prompt provides a beautiful terminal experience with git status, command duration, etc. 10 | - PIN protection (recommended) prevents unauthorized access 11 | - Use the data directory to persist files between container restarts 12 | - Demo mode available for testing and demonstrations - simulated terminal (set DEMO_MODE=true) 13 | 14 | ## Use cases: 15 | 16 | - Build with docker: To create a sandboxed environment for testing scripts, code, applications, emulate a VPS, showcase examples and more. All without having to install dependencies on your local machine! 17 | - Build locally: To access your client's cli/terminal through your browser instead! 18 | - Self-hosting: Put behind a reverse proxy, auth provider (like authentik, authelia, etc), Cloudflare tunnels with application access rules, etc for secure external access. 19 | - Another alternative to web terminals such as ttyd, shellinabox, etc 20 | 21 | ## Table of Contents 22 | 23 | - [Features](#features) 24 | - [Quick Start](#quick-start) 25 | - [Prerequisites](#prerequisites) 26 | - [Option 1: Docker (For Dummies)](#option-1-docker-for-dummies) 27 | - [Option 2: Docker Compose (Recommended)](#option-2-docker-compose-for-dummies-who-like-customizing---recommended) 28 | - [Option 3: Running Locally](#option-3-running-locally-for-developers) 29 | - [Windows-specific](#windows-specific) 30 | - [Configuration](#configuration) 31 | - [Environment Variables](#environment-variables) 32 | - [Starship usage](#starship-usage) 33 | - [Starship Presets](#starship-presets) 34 | - [Keyboard Shortcuts](#keyboard-shortcuts) 35 | - [Terminal Search](#terminal-search) 36 | - [Tab Management](#tab-management) 37 | - [Security](#security) 38 | - [Features](#features-1) 39 | - [Technical Details](#technical-details) 40 | - [Stack](#stack) 41 | - [Dependencies](#dependencies) 42 | - [Supported XTerm Addons](#supported-xterm-addons) 43 | - [Links](#links) 44 | - [Contributing](#contributing) 45 | - [Check Us Out](#-check-us-out) 46 | - [Future Features](#future-features) 47 | 48 | ## Features 49 | 50 | - 🖥️ Web-based terminal access from anywhere 51 | - 🌙 Dark mode support 52 | - 📱 Responsive design for mobile and desktop 53 | - 🐳 Docker support (Debian-based container) 54 | - 🔧 Pre-installed utilities: starship, nerdfonts, apt-utils, curl, wget, ssh, git, vim, nano, htop, net-tools, iputils-ping, traceroute, fontconfig, unzip, locales, python3, etc. 55 | - 🔒 Optional PIN protection (numeric) 56 | - ✨ Starship prompt integration for beautiful terminal experience 57 | - 🔍 Terminal search functionality (`ctrl+f` or `cmd+f`) 58 | - 📂 Custom volume mappings 59 | - 🔗 In-terminal hyperlinks 60 | - ⌨️ Keyboard shortcuts for common actions 61 | - 📑 Tab Management: 62 | - Drag and drop reordering of tabs 63 | - Double-click to rename tabs 64 | - Direct tab selection with shortcuts 65 | - Terminal history persistence across sessions 66 | - 📱 PWA Support for mobile and desktop 67 | - ⚡ XTerm.js for fast terminal rendering 68 | 69 | ## Quick Start 70 | 71 | ### Prerequisites 72 | 73 | - Docker (recommended) 74 | - Node.js >=20.0.0 (for local development) 75 | - _Windows-specific_: [WSL or Node.js v16 - Option 3: Running Locally](#option-3-running-locally-for-developers) 76 | 77 | ### Option 1: Docker (For Dummies) 78 | 79 | - Docker method uses a pre-installed Debian 13 Trixie-based container/environment. 80 | 81 | ```bash 82 | # Pull and run with one command 83 | docker run -p 3000:3000 \ 84 | -v ./data:/root/data \ 85 | -v ./config:/root/.config \ 86 | -e DUMBTERM_PIN=1234 \ 87 | -e SITE_TITLE=DumbTerm \ 88 | -e BASE_URL=http://localhost:3000 \ 89 | -e ALLOWED_ORIGINS=http://localhost:3000 \ 90 | -e ENABLE_STARSHIP=true \ 91 | -e TZ=America/Los_Angeles \ 92 | -e LOCKOUT_TIME=15 \ 93 | -e MAX_SESSION_AGE=24 \ 94 | dumbwareio/dumbterm:latest 95 | ``` 96 | 97 | 1. Go to http://localhost:3000 98 | 2. Enter your PIN if configured 99 | 3. Enjoy your web-based terminal! 100 | 101 | ### Option 2: Docker Compose (For Dummies who like customizing) - Recommended 102 | 103 | Create a `docker-compose.yml` file or use the provided one: 104 | 105 | ```yaml 106 | services: 107 | dumbterm: 108 | image: dumbwareio/dumbterm:latest 109 | container_name: dumbterm 110 | restart: unless-stopped 111 | ports: 112 | - ${DUMBTERM_PORT:-3000}:3000 113 | volumes: 114 | - ${DUMBTERM_CONFIG:-./config}:/root/.config 115 | - ${DUMBTERM_DATA_DIR:-./data}:/root/data 116 | environment: 117 | # Container timezone 118 | TZ: ${DUMBTERM_TZ:-America/Los_Angeles} 119 | # The title shown in the web interface 120 | SITE_TITLE: ${DUMBTERM_SITE_TITLE:-DumbTerm} 121 | # Recommended PIN protection (leave empty to disable) 122 | DUMBTERM_PIN: ${DUMBTERM_PIN:-1234} 123 | # The base URL for the application 124 | BASE_URL: ${DUMBTERM_BASE_URL:-http://localhost:3000} 125 | ENABLE_STARSHIP: ${ENABLE_STARSHIP:-true} 126 | LOCKOUT_TIME: ${DUMBTERM_LOCKOUT_TIME:-15} # Minutes 127 | # Session duration in hours before requiring re-authentication 128 | MAX_SESSION_AGE: ${DUMBTERM_MAX_SESSION_AGE:-24} # Hours 129 | # (OPTIONAL) - List of allowed origins for CORS 130 | # ALLOWED_ORIGINS: ${DUMBTERM_ALLOWED_ORIGINS:-http://localhost:3000} 131 | ``` 132 | 133 | Then run: 134 | 135 | ```bash 136 | docker compose up -d 137 | ``` 138 | 139 | ### Option 3: Running Locally (For Developers) 140 | 141 | - Local method will use your local terminal environment (Windows: Powershell, Mac / Linux: bash). 142 | 143 | 1. Install dependencies: 144 | 145 | ```bash 146 | npm install 147 | ``` 148 | 149 | > [!TIP] 150 | > 151 | > #### Windows specific: 152 | > 153 | > - If you encounter issues with `node-pty` you can try using [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) or may need to install: 154 | > - `Node.js v16` (Look into [nvm for windows](https://github.com/coreybutler/nvm-windows) for multi node version support): 155 | > - `winget install CoreyButler.NVMforWindows` 156 | > - with nvm installed use: `nvm install 16 && nvm use 16` 157 | > - `windows-build-tools` through Visual Studio Installer `MSVC v142 - VS 2019 C++ {arch} Build Tools` 158 | > - Contact us or View the [official Microsoft documentation](https://github.com/microsoft/node-pty?tab=readme-ov-file#windows) for more details 159 | 160 | 2. `cp .env.example .env` > Set environment variables in `.env`: 161 | 162 | ```bash 163 | PORT=3000 # Port to run the server on 164 | DUMBTERM_PIN=1234 # Optional PIN protection 165 | SITE_TITLE=DumbTerm # Custom site title 166 | BASE_URL=http://localhost:3000 # Base URL for the application 167 | ``` 168 | 169 | 3. Start the server: 170 | 171 | ```bash 172 | npm start 173 | ``` 174 | 175 | ## Configuration 176 | 177 | ### Environment Variables 178 | 179 | | Variable | Description | Default | Required | 180 | | --------------- | ------------------------------------------------- | --------------------- | -------- | 181 | | PORT | Server port | 3000 | No | 182 | | BASE_URL | Base URL for the application | http://localhost:PORT | No | 183 | | DUMBTERM_PIN | PIN protection (numeric) | None | No | 184 | | SITE_TITLE | Site title displayed in header | DumbTerm | No | 185 | | TZ | Container timezone | America/Los_Angeles | No | 186 | | ENABLE_STARSHIP | Enable Starship prompt (docker only) | true | No | 187 | | NODE_ENV | Node environment mode (development or production) | production | No | 188 | | ALLOWED_ORIGINS | Allowed CORS origins (comma-separated list) | \* (all origins) | No | 189 | | DEBUG | Enable debug logging | FALSE | No | 190 | | LOCKOUT_TIME | Custom Pin Lockout Time (in minutes) | 15 | No | 191 | | MAX_SESSION_AGE | Duration of authenticated session (in hours) | 24 | No | 192 | | DEMO_MODE | Enable demo mode with simulated terminal | false | No | 193 | 194 | ### Starship usage 195 | 196 | - Starship is a cross-shell prompt that provides a beautiful terminal experience. 197 | - It is enabled by default in the Docker image and is the recommended method. 198 | - To disable it, set `ENABLE_STARSHIP` to `false` in your environment variables. 199 | - You can customize the Starship prompt by using one of the following steps: 200 | 201 | ### 1. Use a preset configuration from starship. 202 | 203 | #### Starship Presets: 204 | 205 | > [!TIP] 206 | > copy and paste one of the starship preset commands below into DumbTerm and that's it! 207 | 208 |
209 | Example Preset Command: 210 | 211 | ![preset-preview](https://github.com/user-attachments/assets/affdd780-5471-40de-adfd-9242feeec9da) 212 | 213 |
214 | 215 |
216 | 217 | > [!WARNING] > **Note:** If running locally or mapped volume to your actual `starship.toml` config, the preset commands will overwrite your existing `starship.toml` file. Make sure to back it up as needed. 218 | 219 |
220 | View All Starship Presets: 221 | 222 | | Preset Name | Command | Preview | 223 | | ------------------------------------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------- | 224 | | Nerd Font Symbols | `starship preset nerd-font-symbols -o ~/.config/starship.toml` | ![Nerd Font Symbols](https://starship.rs/presets/img/nerd-font-symbols.png) | 225 | | Bracketed Segments | `starship preset bracketed-segments -o ~/.config/starship.toml` | ![Bracketed Segments](https://starship.rs/presets/img/bracketed-segments.png) | 226 | | Plain Text Symbols | `starship preset plain-text-symbols -o ~/.config/starship.toml` | ![Plain Text Symbols](https://starship.rs/presets/img/plain-text-symbols.png) | 227 | | No Runtime Versions | `starship preset no-runtime-versions -o ~/.config/starship.toml` | ![No Runtime Versions](https://starship.rs/presets/img/no-runtime-versions.png) | 228 | | No Empty Icons | `starship preset no-empty-icons -o ~/.config/starship.toml` | ![No Empty Icons](https://starship.rs/presets/img/no-empty-icons.png) | 229 | | Pure Prompt | `starship preset pure-preset -o ~/.config/starship.toml` | ![Pure Prompt](https://starship.rs/presets/img/pure-preset.png) | 230 | | Pastel Powerline | `starship preset pastel-powerline -o ~/.config/starship.toml` | ![Pastel Powerline](https://starship.rs/presets/img/pastel-powerline.png) | 231 | | Tokyo Night `(DumbTerm Default with mods)` | `starship preset tokyo-night -o ~/.config/starship.toml` | ![Tokyo Night](https://starship.rs/presets/img/tokyo-night.png) | 232 | | Gruvbox Rainbow | `starship preset gruvbox-rainbow -o ~/.config/starship.toml` | ![Gruvbox Rainbow](https://starship.rs/presets/img/gruvbox-rainbow.png) | 233 | | Jetpack | `starship preset jetpack -o ~/.config/starship.toml` | ![Jetpack](https://starship.rs/presets/img/jetpack.png) | 234 | | No Nerd Fonts | `starship preset no-nerd-font -o ~/.config/starship.toml` | n/a | 235 | 236 |
237 | 238 | - You can also view the available presets by running `starship preset -l` in DumbTerm. 239 | 240 | For more details, visit the [Starship Presets page](https://starship.rs/presets/). 241 | 242 | ### 2. Modify the `~/.config/starship.toml` file in your set volume mount or within the container. 243 | 244 | - The default configuration is located in `/root/.config/starship.toml`. 245 | - You can also mount a custom `starship.toml` file to the container using Docker volumes. 246 | - Update or add your custom configurations to starship.toml. 247 | - Visit [Starship Configuration](https://starship.rs/config/) for more information on customizing the prompt. 248 | 249 | ### 3. Running locally 250 | 251 | - If you are running DumbTerm locally, DumbTerm will inherit your current styles. 252 | - Meaning if you already have starship enabled locally, you should be able to see your current styles accordingly. 253 | - You must install Starship on your local machine if you wish to use DumbTerm with starship _locally_. 254 | - To install Starship, follow the instructions on the [Starship installation page](https://starship.rs/installing/). 255 | 256 | ## Keyboard Shortcuts 257 | 258 | DumbTerm provides a comprehensive set of keyboard shortcuts for efficient terminal management. The modifier keys vary by operating system: 259 | 260 | - Windows/Linux: `Ctrl+Alt+{key}` 261 | - macOS: `Ctrl+Cmd+{key}` 262 | 263 | | Action | Windows/Linux | macOS | 264 | | ---------------------- | ---------------------------- | ---------------------------- | 265 | | New Terminal | `Ctrl+Alt+T` | `Ctrl+Cmd+T` | 266 | | Close Terminal | `Ctrl+Alt+W` | `Ctrl+Cmd+W` | 267 | | Rename Terminal | `Ctrl+Alt+R` | `Ctrl+Cmd+R` | 268 | | Search in Terminal | `Ctrl+F` | `Cmd+F` | 269 | | Next Terminal | `Ctrl+Alt+>` or `Ctrl+Alt+.` | `Ctrl+Cmd+>` or `Ctrl+Cmd+.` | 270 | | Previous Terminal | `Ctrl+Alt+<` or `Ctrl+Alt+,` | `Ctrl+Cmd+<` or `Ctrl+Cmd+,` | 271 | | Switch to Terminal 1-9 | `Ctrl+Alt+[1-9]` | `Ctrl+Cmd+[1-9]` | 272 | 273 | ### Terminal Search 274 | 275 | - Press `Ctrl+F` (Windows/Linux) or `Cmd+F` (macOS) to open the search bar 276 | - Use Up/Down arrow buttons or Enter/Shift+Enter to cycle through matches 277 | - Press Escape or the close button to exit search mode 278 | 279 | ### Tab Management 280 | 281 | - **Drag and Drop**: Click and drag tabs to reorder them 282 | - **Rename**: Double-click a tab or use the keyboard shortcut to rename it 283 | - **History**: Terminal content is automatically preserved across browser refreshes and restarts 284 | - **Direct Selection**: Use number shortcuts (1-9) to quickly switch between the first 9 terminals 285 | 286 | ## Security 287 | 288 | > It is highly recommended to set a strong PIN, use HTTPS, use ALLOWED_ORIGINS, and integrate with an auth provider (i.e. Authentik / Authelia / tinyauth, etc). 289 | 290 | We're dumb, but not stupid. Use a full-featured auth provider for production use. 291 | 292 | - https://github.com/goauthentik/authentik (More difficult to set up, but production ready) 293 | - https://github.com/authelia/authelia 294 | - https://github.com/steveiliop56/tinyauth (Easy with docker compose integration) 295 | 296 | ### Features 297 | 298 | - Variable-length PIN support (numeric) 299 | - Constant-time PIN comparison 300 | - Brute force protection: 301 | - 5 attempts maximum 302 | - 15-minute lockout after failed attempts 303 | - IP-based tracking 304 | - Secure cookie handling 305 | - Session-based authentication 306 | - CORS support for origin restrictions (optional) 307 | - HTTPS support (when configured with proper BASE_URL) 308 | 309 | ## Technical Details 310 | 311 | ### Stack 312 | 313 | - **Backend**: Node.js (>=20.0.0) with Express 314 | - **Frontend**: XTerm.js for terminal emulation 315 | - **Container**: Docker with Debian Trixie (as of v1.2.1+) base (v1.1.1 for Bullseye) 316 | - **Terminal**: node-pty for process spawning 317 | - **Communication**: WebSockets for real-time terminal I/O 318 | - **Security**: cors for cross-origin requests 319 | 320 | 321 | ### Dependencies 322 | 323 | - express: Web framework 324 | - node-pty: Terminal process spawning 325 | - xterm: Terminal frontend 326 | - ws: WebSocket server 327 | - cookie-parser: Cookie handling 328 | - express-session: Session management 329 | - cors: security for cross-origin requests 330 | 331 | 332 | ### Supported XTerm Addons 333 | 334 | DumbTerm includes the following XTerm.js addons to enhance your terminal experience: 335 | 336 | | Addon | Description | 337 | | -------------------------- | --------------------------------------------------------------------------------- | 338 | | **@xterm/addon-attach** | Attaches a terminal session to a websocket | 339 | | **@xterm/addon-canvas** | Renderer that uses canvas to draw terminal content (used as fallback after webgl) | 340 | | **@xterm/addon-clipboard** | Clipboard integration for copy/paste support | 341 | | **@xterm/addon-fit** | Automatically resize terminal to fit its container | 342 | | **@xterm/addon-image** | Display images inline in the terminal | 343 | | **@xterm/addon-ligatures** | Font ligatures support | 344 | | **@xterm/addon-search** | Search text in the terminal buffer | 345 | | **@xterm/addon-serialize** | Serialize terminal output to string or HTML | 346 | | **@xterm/addon-unicode11** | Additional Unicode 11 character width support | 347 | | **@xterm/addon-web-links** | Automatically hyperlink URLs in terminal | 348 | | **@xterm/addon-webgl** | Renderer that uses WebGL for better performance (default render method) | 349 | 350 | ## Links 351 | 352 | - GitHub: [github.com/dumbwareio/dumbterm](https://github.com/dumbwareio/dumbterm) 353 | - Docker Hub: [hub.docker.com/r/dumbwareio/dumbterm](https://hub.docker.com/r/dumbwareio/dumbterm) 354 | 355 | ## Contributing 356 | 357 | 1. Fork the repository 358 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 359 | 3. Commit your changes using conventional commits 360 | 4. Push to the branch (`git push origin feature/amazing-feature`) 361 | 5. Open a Pull Request 362 | 363 | See Development Guide for local setup and guidelines. 364 | 365 | --- 366 | 367 | Made with ❤️ by [DumbWareio](https://github.com/dumbwareio) & [gitmotion](https://github.com/gitmotion) 368 | 369 | ## 🌐 Check Us Out 370 | 371 | - **Website:** [dumbware.io](https://www.dumbware.io/) 372 | - **Join the Chaos:** [Discord](https://discord.gg/zJutzxWyq2) 💬 373 | 374 | ## Support the Project 375 | 376 | 377 | Buy Me A Coffee 378 | 379 | 380 | ## Future Features 381 | 382 | - Additional authentication methods 383 | 384 | > Got an idea? Open an issue or submit a PR 385 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const session = require('express-session'); 4 | const crypto = require('crypto'); 5 | const path = require('path'); 6 | const cookieParser = require('cookie-parser'); 7 | const cors = require('cors'); 8 | const { WebSocketServer } = require('ws'); 9 | const http = require('http'); 10 | const pty = require('node-pty'); 11 | const os = require('os'); 12 | const { generatePWAManifest } = require("./scripts/pwa-manifest-generator"); 13 | const { originValidationMiddleware, getCorsOptions, validateOrigin } = require('./scripts/cors'); 14 | 15 | const app = express(); 16 | const server = http.createServer(app); 17 | 18 | const PORT = process.env.PORT || 3000; 19 | const DEBUG = process.env.DEBUG === 'TRUE'; 20 | const NODE_ENV = process.env.NODE_ENV || 'production'; 21 | const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; 22 | const DEMO_MODE = process.env.DEMO_MODE === 'true'; 23 | const SITE_TITLE = DEMO_MODE ? `${process.env.SITE_TITLE || 'DumbTerm'} (DEMO)` : (process.env.SITE_TITLE || 'DumbTerm'); 24 | const APP_VERSION = require('./package.json').version; 25 | const PUBLIC_DIR = path.join(__dirname, 'public'); 26 | const ptyModule = DEMO_MODE ? require('./scripts/demo/terminal') : pty; 27 | const ASSETS_DIR = path.join(PUBLIC_DIR, 'assets'); 28 | const CACHE_NAME = `DUMBTERM_CACHE_V${APP_VERSION}`; 29 | 30 | generatePWAManifest(SITE_TITLE); 31 | 32 | function debugLog(...args) { 33 | if (DEBUG) { 34 | console.log('[DEBUG]', ...args); 35 | } 36 | } 37 | 38 | // Base URL configuration 39 | const BASE_PATH = (() => { 40 | if (!BASE_URL) { 41 | debugLog('No BASE_URL set, using empty base path'); 42 | return ''; 43 | } 44 | try { 45 | const url = new URL(BASE_URL); 46 | const path = url.pathname.replace(/\/$/, ''); // Remove trailing slash 47 | debugLog('Base URL Configuration:', { 48 | originalUrl: BASE_URL, 49 | extractedPath: path, 50 | protocol: url.protocol, 51 | hostname: url.hostname 52 | }); 53 | return path; 54 | } catch { 55 | // If BASE_URL is just a path (e.g. /app) 56 | const path = BASE_URL.replace(/\/$/, ''); 57 | debugLog('Using direct path as BASE_URL:', path); 58 | return path; 59 | } 60 | })(); 61 | 62 | // Get the project name from package.json to use for the PIN environment variable 63 | const projectName = require('./package.json').name.toUpperCase().replace(/-/g, '_'); 64 | const PIN = process.env[`${projectName}_PIN`]; 65 | const isPinRequired = PIN && PIN.trim() !== ''; 66 | 67 | // Log whether PIN protection is enabled 68 | if (isPinRequired) { 69 | debugLog('PIN protection is enabled, PIN length:', PIN.length); 70 | } else { 71 | debugLog('PIN protection is disabled'); 72 | } 73 | 74 | // Brute force protection 75 | const loginAttempts = new Map(); 76 | const MAX_ATTEMPTS = 5; 77 | const LOCKOUT_TIME = (process.env.LOCKOUT_TIME || 15) * 60 * 1000; // default 15 minutes in milliseconds 78 | const MAX_SESSION_AGE = (process.env.MAX_SESSION_AGE || 24) * 60 * 60 * 1000 // default 24 hours 79 | 80 | function resetAttempts(ip) { 81 | debugLog('Resetting login attempts for IP:', ip); 82 | loginAttempts.delete(ip); 83 | } 84 | 85 | function isLockedOut(ip) { 86 | const attempts = loginAttempts.get(ip); 87 | if (!attempts) return false; 88 | 89 | if (attempts.count >= MAX_ATTEMPTS) { 90 | const timeElapsed = Date.now() - attempts.lastAttempt; 91 | if (timeElapsed < LOCKOUT_TIME) { 92 | debugLog('IP is locked out:', ip, 'Time remaining:', Math.ceil((LOCKOUT_TIME - timeElapsed) / 1000 / 60), 'minutes'); 93 | return true; 94 | } 95 | resetAttempts(ip); 96 | } 97 | return false; 98 | } 99 | 100 | function recordAttempt(ip) { 101 | const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; 102 | attempts.count += 1; 103 | attempts.lastAttempt = Date.now(); 104 | loginAttempts.set(ip, attempts); 105 | debugLog('Login attempt recorded for IP:', ip, 'Count:', attempts.count); 106 | } 107 | 108 | // Security middleware 109 | app.set('trust proxy', 1); 110 | app.use(cors(getCorsOptions(BASE_URL))); 111 | app.use(express.json()); 112 | app.use(cookieParser()); 113 | 114 | // Session configuration with secure settings 115 | app.use(session({ 116 | secret: crypto.randomBytes(32).toString('hex'), 117 | resave: false, 118 | saveUninitialized: false, 119 | cookie: { 120 | secure: (BASE_URL.startsWith('https') && NODE_ENV === 'production'), 121 | httpOnly: true, 122 | sameSite: 'strict', 123 | maxAge: MAX_SESSION_AGE 124 | } 125 | })); 126 | 127 | const requirePin = (req, res, next) => { 128 | if (!PIN || !isValidPin(PIN)) { 129 | return next(); 130 | } 131 | 132 | const authCookie = req.cookies[COOKIE_NAME]; 133 | if (!authCookie || !secureCompare(authCookie, PIN)) { 134 | return res.status(401).json({ error: 'Unauthorized' }); 135 | } 136 | next(); 137 | }; 138 | 139 | // Constant-time PIN comparison to prevent timing attacks 140 | const verifyPin = (storedPin, providedPin) => { 141 | if (!storedPin || !providedPin) return false; 142 | if (storedPin.length !== providedPin.length) return false; 143 | 144 | try { 145 | return crypto.timingSafeEqual( 146 | Buffer.from(storedPin), 147 | Buffer.from(providedPin) 148 | ); 149 | } catch { 150 | return false; 151 | } 152 | } 153 | 154 | // Authentication middleware 155 | const authMiddleware = (req, res, next) => { 156 | debugLog('Auth check for path:', req.path, 'Method:', req.method); 157 | 158 | // If no PIN is set or PIN is empty, bypass authentication completely 159 | if (!PIN || PIN.trim() === '') { 160 | debugLog('Auth bypassed - No PIN configured'); 161 | req.session.authenticated = true; // Set session as authenticated 162 | return next(); 163 | } 164 | 165 | // First check if user is authenticated via session 166 | if (req.session.authenticated) { 167 | debugLog('Auth successful - Valid session found'); 168 | return next(); 169 | } 170 | 171 | // If not authenticated via session, check for valid PIN cookie 172 | const pinCookie = req.cookies[`${projectName}_PIN`]; 173 | if (pinCookie && verifyPin(PIN, pinCookie)) { 174 | debugLog('Auth successful - Valid PIN cookie found, restoring session'); 175 | req.session.authenticated = true; 176 | return next(); 177 | } 178 | 179 | // No valid session or PIN cookie found 180 | debugLog('Auth failed - No valid session or PIN cookie, redirecting to login'); 181 | return res.redirect(BASE_PATH + '/login'); 182 | }; 183 | 184 | // Global middleware for origin validation and authentication 185 | app.use(BASE_PATH, (req, res, next) => { 186 | // List of paths that should be publicly accessible 187 | const publicPaths = [ 188 | '/login', 189 | '/pin-length', 190 | '/verify-pin', 191 | '/config.js', 192 | '/assets/', 193 | '/fonts/', 194 | '/styles.css', 195 | '/manifest.json', 196 | '/asset-manifest.json', 197 | ]; 198 | 199 | // Check if the current path matches any of the public paths 200 | if (publicPaths.some(path => req.path.startsWith(path))) { 201 | return next(); 202 | } 203 | 204 | // For all other paths, apply both origin validation and auth middleware 205 | originValidationMiddleware(req, res, () => { 206 | authMiddleware(req, res, next); 207 | }); 208 | }); 209 | 210 | // Routes start here... 211 | app.get(BASE_PATH + '/', (req, res) => { 212 | res.sendFile(path.join(__dirname, 'public', 'index.html')); 213 | }); 214 | 215 | // Serve config.js for frontend 216 | app.get(BASE_PATH + '/config.js', (req, res) => { 217 | debugLog('Serving config.js with basePath:', BASE_PATH); 218 | const appConfig = { 219 | basePath: BASE_PATH, 220 | debug: DEBUG, 221 | siteTitle: SITE_TITLE, 222 | isPinRequired: isPinRequired, 223 | isDemoMode: DEMO_MODE, 224 | version: APP_VERSION, 225 | cacheName: CACHE_NAME 226 | } 227 | 228 | res.type('application/javascript').send(` 229 | window.appConfig = ${JSON.stringify(appConfig)}; 230 | `); 231 | }); 232 | 233 | // Serve static files for public assets 234 | app.use(BASE_PATH + '/', express.static(path.join(PUBLIC_DIR))); 235 | app.get(BASE_PATH + "/manifest.json", (req, res) => { 236 | res.sendFile(path.join(ASSETS_DIR, "manifest.json")); 237 | }); 238 | app.get(BASE_PATH + "/asset-manifest.json", (req, res) => { 239 | res.sendFile(path.join(ASSETS_DIR, "asset-manifest.json")); 240 | }); 241 | // Serve font files 242 | app.use('/fonts', express.static(path.join(PUBLIC_DIR, 'fonts'))); 243 | app.use('/node_modules/@xterm/', express.static( 244 | path.join(__dirname, 'node_modules/@xterm/') 245 | )); 246 | 247 | // Routes 248 | app.get(BASE_PATH + '/login', (req, res) => { 249 | // Check if PIN is required 250 | if (!PIN || PIN.trim() === '') { 251 | return res.redirect(BASE_PATH + '/'); 252 | } 253 | 254 | // Check authentication first 255 | if (req.session.authenticated) { 256 | return res.redirect(BASE_PATH + '/'); 257 | } 258 | 259 | 260 | // Check if user is already authenticated by PIN 261 | const pinCookie = req.cookies[`${projectName}_PIN`]; 262 | if (verifyPin(PIN, pinCookie)) { 263 | return res.redirect(BASE_PATH + '/'); 264 | } 265 | 266 | res.sendFile(path.join(__dirname, 'public', 'login.html')); 267 | }); 268 | 269 | app.get(BASE_PATH + '/pin-length', (req, res) => { 270 | // If no PIN is set, return 0 length 271 | if (!PIN || PIN.trim() === '') { 272 | return res.json({ length: 0 }); 273 | } 274 | res.json({ length: PIN.length }); 275 | }); 276 | 277 | app.post(BASE_PATH + '/verify-pin', (req, res) => { 278 | debugLog('PIN verification attempt from IP:', req.ip); 279 | 280 | // If no PIN is set, authentication is successful 281 | if (!PIN || PIN.trim() === '') { 282 | debugLog('PIN verification bypassed - No PIN configured'); 283 | req.session.authenticated = true; 284 | return res.status(200).json({ success: true }); 285 | } 286 | 287 | const ip = req.ip; 288 | 289 | // Check if IP is locked out 290 | if (isLockedOut(ip)) { 291 | const attempts = loginAttempts.get(ip); 292 | const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60); 293 | debugLog('PIN verification blocked - IP is locked out:', ip); 294 | return res.status(429).json({ 295 | error: `Too many attempts. Please try again in ${timeLeft} minutes.` 296 | }); 297 | } 298 | 299 | const { pin } = req.body; 300 | 301 | if (!pin || typeof pin !== 'string') { 302 | debugLog('PIN verification failed - Invalid PIN format'); 303 | return res.status(400).json({ error: 'Invalid PIN format' }); 304 | } 305 | 306 | // Verify PIN first 307 | const isPinValid = verifyPin(PIN, pin); 308 | 309 | if (isPinValid) { 310 | debugLog('PIN verification successful'); 311 | // Reset attempts on successful login 312 | resetAttempts(ip); 313 | 314 | // Set authentication in session immediately 315 | req.session.authenticated = true; 316 | 317 | // Set secure cookie 318 | res.cookie(`${projectName}_PIN`, pin, { 319 | httpOnly: true, 320 | secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), 321 | sameSite: 'strict', 322 | maxAge: MAX_SESSION_AGE 323 | }); 324 | 325 | // Add artificial delay before sending response 326 | setTimeout(() => { 327 | // Redirect to main page on success 328 | res.redirect(BASE_PATH + '/'); 329 | }, crypto.randomInt(50, 150)); 330 | } else { 331 | debugLog('PIN verification failed - Invalid PIN'); 332 | // Record failed attempt 333 | recordAttempt(ip); 334 | 335 | const attempts = loginAttempts.get(ip); 336 | const attemptsLeft = MAX_ATTEMPTS - attempts.count; 337 | 338 | // Add artificial delay before sending error response 339 | setTimeout(() => { 340 | res.status(401).json({ 341 | error: 'Invalid PIN', 342 | attemptsLeft: Math.max(0, attemptsLeft) 343 | }); 344 | }, crypto.randomInt(50, 150)); 345 | } 346 | }); 347 | 348 | app.get(BASE_PATH + '/api/require-pin', (req, res) => { 349 | // If no PIN is set, return success 350 | if (!PIN || PIN.trim() === '') { 351 | return res.json({ success: true, required: false }); 352 | } 353 | 354 | // Check for PIN cookie 355 | const pinCookie = req.cookies[`${projectName}_PIN`]; 356 | if (!pinCookie || !verifyPin(PIN, pinCookie)) { 357 | return res.json({ success: false, required: true }); 358 | } 359 | 360 | // Valid PIN cookie found 361 | return res.json({ success: true, required: true }); 362 | }); 363 | 364 | // Logout endpoint 365 | app.post(BASE_PATH + '/logout', (req, res) => { 366 | debugLog('Logout request received'); 367 | 368 | const cookieOptions = { 369 | httpOnly: true, 370 | secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), 371 | sameSite: 'strict', 372 | path: BASE_PATH || '/', // Match the path used when setting cookies 373 | expires: new Date(0), // Immediately expire the cookie 374 | maxAge: 0 // Belt and suspenders - also set maxAge to 0 375 | }; 376 | 377 | // Clear both cookies with consistent options 378 | res.clearCookie(`${projectName}_PIN`, cookieOptions); 379 | res.clearCookie('connect.sid', cookieOptions); 380 | 381 | // Destroy the session and wait for it to be destroyed before sending response 382 | req.session.destroy((err) => { 383 | if (err) { 384 | debugLog('Error destroying session:', err); 385 | return res.status(500).json({ success: false, error: 'Failed to logout properly' }); 386 | } 387 | debugLog('Session and cookies cleared successfully'); 388 | res.json({ success: true }); 389 | }); 390 | }); 391 | 392 | // WebSocket server configuration 393 | const wss = new WebSocketServer({ 394 | server, 395 | verifyClient: (info, callback) => { 396 | debugLog('Verifying WebSocket connection from:', info.req.headers.origin); 397 | 398 | const isOriginValid = validateOrigin(info.req.headers.origin); 399 | if (isOriginValid) callback(true); // allow the connection 400 | else { 401 | console.warn("Blocked connection from origin:", {origin}); 402 | callback(false, 403, 'Forbidden'); // reject the connection 403 | } 404 | } 405 | }); 406 | 407 | // WebSocket connection handling 408 | wss.on('connection', (ws, req) => { 409 | debugLog('New WebSocket connection attempt'); 410 | 411 | // Keep track of connection status 412 | ws.isAlive = true; 413 | 414 | // Setup ping/pong heartbeat 415 | ws.on('pong', () => { 416 | ws.isAlive = true; 417 | debugLog('Received pong from client'); 418 | }); 419 | 420 | // Handle connection errors 421 | ws.on('error', (error) => { 422 | debugLog('WebSocket error:', error); 423 | ws.terminate(); 424 | }); 425 | 426 | // If no PIN is set, allow the connection 427 | if (!PIN || PIN.trim() === '') { 428 | debugLog('No PIN required, creating terminal'); 429 | createTerminal(ws); 430 | return; 431 | } 432 | 433 | if (!req.headers.cookie) { 434 | debugLog('No cookies found, closing connection'); 435 | ws.close(1008, 'Authentication required'); // Use 1008 for policy violation 436 | return; 437 | } 438 | 439 | // Parse the cookies 440 | const parsedCookies = {}; 441 | req.headers.cookie.split(';').forEach(cookie => { 442 | const parts = cookie.split('='); 443 | parsedCookies[parts[0].trim()] = parts[1].trim(); 444 | }); 445 | 446 | // Check if the user has the correct PIN cookie 447 | const pinCookie = parsedCookies[`${projectName}_PIN`]; 448 | if (!pinCookie || !verifyPin(PIN, pinCookie)) { 449 | debugLog('Invalid PIN cookie, closing connection'); 450 | ws.close(1008, 'Authentication required'); // Use 1008 for policy violation 451 | return; 452 | } 453 | 454 | debugLog('Authentication successful, creating terminal'); 455 | createTerminal(ws); 456 | }); 457 | 458 | // Heartbeat check interval with more frequent checks 459 | const heartbeatInterval = setInterval(() => { 460 | wss.clients.forEach((ws) => { 461 | if (ws.isAlive === false) { 462 | debugLog('Terminating inactive WebSocket connection'); 463 | return ws.terminate(); 464 | } 465 | ws.isAlive = false; 466 | try { 467 | ws.ping(); 468 | } catch (err) { 469 | debugLog('Error sending ping:', err); 470 | ws.terminate(); 471 | } 472 | }); 473 | }, 15000); // Check every 15 seconds 474 | 475 | // Clean up interval on server shutdown 476 | process.on('SIGTERM', () => { 477 | clearInterval(heartbeatInterval); 478 | wss.close(); 479 | }); 480 | 481 | // Terminal creation helper function 482 | function createTerminal(ws) { 483 | // Create terminal process 484 | const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : 'bash'); 485 | 486 | const term = ptyModule.spawn(shell, [], { 487 | name: 'xterm-256color', 488 | cols: 80, 489 | rows: 24, 490 | cwd: DEMO_MODE ? '/home/demo' : (process.env.HOME || '/root'), 491 | env: { 492 | ...process.env, 493 | TERM: 'xterm-256color', 494 | COLORTERM: 'truecolor', 495 | LANG: 'en_US.UTF-8', 496 | // Force buffer flushing for better alternate buffer handling 497 | STDBUF: 'L', 498 | // Ensure proper handling of alternate buffer in applications 499 | TERM_PROGRAM: 'xterm-256color' 500 | } 501 | }); 502 | 503 | debugLog(`Terminal created with PID: ${term.pid}${DEMO_MODE ? ' (Demo Mode)' : ''}`); 504 | 505 | // Handle incoming data from client 506 | ws.on('message', (data) => { 507 | try { 508 | const message = JSON.parse(data); 509 | switch(message.type) { 510 | case 'input': 511 | term.write(message.data); 512 | break; 513 | case 'resize': 514 | term.resize(message.cols, message.rows); 515 | break; 516 | } 517 | } catch (error) { 518 | debugLog('Error processing WebSocket message:', error); 519 | } 520 | }); 521 | 522 | // Handle terminal data with control sequence filtering 523 | term.on('data', (data) => { 524 | if (ws.readyState === ws.OPEN) { 525 | // Raw send without any filtering - let the client handle buffer switches 526 | ws.send(JSON.stringify({ 527 | type: 'output', 528 | data: data 529 | })); 530 | } 531 | }); 532 | 533 | // Clean up on close 534 | ws.on('close', () => { 535 | debugLog('WebSocket closed, killing terminal process:', term.pid); 536 | term.kill(); 537 | }); 538 | 539 | // Handle terminal exit 540 | term.on('exit', (code) => { 541 | debugLog('Terminal process exited with code:', code); 542 | if (ws.readyState === ws.OPEN) { 543 | ws.close(); 544 | } 545 | }); 546 | } 547 | 548 | // Cleanup old lockouts periodically 549 | setInterval(() => { 550 | const now = Date.now(); 551 | for (const [ip, attempts] of loginAttempts.entries()) { 552 | if (now - attempts.lastAttempt >= LOCKOUT_TIME) { 553 | loginAttempts.delete(ip); 554 | } 555 | } 556 | }, 60000); // Clean up every minute 557 | 558 | // Update server.listen to use the http server 559 | server.listen(PORT, () => { 560 | debugLog('Server Configuration:', { 561 | port: PORT, 562 | basePath: BASE_PATH, 563 | pinProtection: isPinRequired, 564 | nodeEnv: NODE_ENV, 565 | debug: DEBUG, 566 | demoMode: DEMO_MODE, 567 | version: APP_VERSION, 568 | cacheName: CACHE_NAME 569 | }); 570 | console.log(`Server running on: ${BASE_URL}`); 571 | }); -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | // Core service worker configuration 2 | let CACHE_VERSION = "1.0.0"; // Default fallback version 3 | let CACHE_NAME = `DUMBTERM_CACHE_V${CACHE_VERSION}`; // Default format, will be overridden 4 | const ASSETS_TO_CACHE = []; 5 | const BASE_PATH = self.registration.scope; 6 | 7 | /** 8 | * Gets app configuration from config.js 9 | * @returns {Promise} App configuration or null if retrieval fails 10 | */ 11 | async function getAppConfig() { 12 | try { 13 | const response = await fetch(getAssetPath('config.js')); 14 | if (!response.ok) throw new Error(`Failed to fetch config: ${response.status}`); 15 | 16 | const configText = await response.text(); 17 | const configJson = configText.match(/window\.appConfig\s*=\s*({[\s\S]*?});/); 18 | 19 | if (configJson && configJson[1]) { 20 | return JSON.parse(configJson[1]); 21 | } 22 | throw new Error('Could not extract config from config.js'); 23 | } catch (error) { 24 | console.error('Error fetching app config:', error); 25 | return null; 26 | } 27 | } 28 | 29 | /** 30 | * Initializes service worker version from different sources in order of priority: 31 | * 1. URL query parameter 32 | * 2. App config from config.js 33 | * 3. Default version (1.0.0) 34 | * 35 | * @returns {Promise} The determined version 36 | */ 37 | async function initializeVersion() { 38 | console.log("Initializing service worker version..."); 39 | try { 40 | // Check for existing cache version first 41 | const { currentCacheExists, existingVersion } = await checkCacheVersion(); 42 | console.log(`Cache check results: currentCacheExists=${currentCacheExists}, existingVersion=${existingVersion}`); 43 | 44 | if (currentCacheExists && existingVersion) { 45 | console.log(`Using existing cache version: ${existingVersion}`); 46 | CACHE_VERSION = existingVersion; 47 | CACHE_NAME = `DUMBTERM_CACHE_V${CACHE_VERSION}`; // Fallback format 48 | 49 | // Send a notification to let clients know about this existing version 50 | setTimeout(() => notifyClients(), 1000); 51 | 52 | return existingVersion; 53 | } 54 | 55 | // Try getting from config.js (second priority) 56 | const appConfig = await getAppConfig(); 57 | if (appConfig) { 58 | console.log("Got app config:", JSON.stringify(appConfig, null, 2)); 59 | 60 | if (appConfig.cacheName) { 61 | console.log(`Found cacheName in config.js: ${appConfig.cacheName}`); 62 | CACHE_NAME = appConfig.cacheName; 63 | 64 | // Extract version from cacheName if possible 65 | const versionMatch = CACHE_NAME.match(/.*_V(.+)$/); 66 | if (versionMatch && versionMatch[1]) { 67 | CACHE_VERSION = versionMatch[1]; 68 | console.log(`Extracted version from cacheName: ${CACHE_VERSION}`); 69 | } else if (appConfig.version) { 70 | CACHE_VERSION = appConfig.version; 71 | console.log(`Using version from config.js: ${CACHE_VERSION}`); 72 | } 73 | 74 | // If we have an existing version that's different from what we just loaded, 75 | // we should notify clients about an available update 76 | if (existingVersion && existingVersion !== CACHE_VERSION) { 77 | console.log(`Different version detected: existing=${existingVersion}, new=${CACHE_VERSION}`); 78 | setTimeout(() => notifyClients(), 1000); 79 | } 80 | 81 | return CACHE_VERSION; 82 | } else if (appConfig.version) { 83 | console.log(`Found version in config.js: ${appConfig.version}`); 84 | CACHE_VERSION = appConfig.version; 85 | CACHE_NAME = appConfig.cacheName || `DUMBTERM_CACHE_V${CACHE_VERSION}`; // Fallback format 86 | 87 | // If we have an existing version that's different from what we just loaded, 88 | // we should notify clients about an available update 89 | if (existingVersion && existingVersion !== CACHE_VERSION) { 90 | console.log(`Different version detected: existing=${existingVersion}, new=${CACHE_VERSION}`); 91 | setTimeout(() => notifyClients(), 1000); 92 | } 93 | 94 | return CACHE_VERSION; 95 | } 96 | } 97 | } catch (error) { 98 | console.error("Error initializing version:", error); 99 | } 100 | 101 | // If we reach here, we're using the default version 102 | console.log(`Using default version: ${CACHE_VERSION}`); 103 | return CACHE_VERSION; 104 | } 105 | 106 | /** 107 | * Helper to prepend base path to URLs that need it 108 | * @param {string} url - URL to prepend base path to 109 | * @returns {string} URL with base path prepended 110 | */ 111 | function getAssetPath(url) { 112 | if (url.startsWith('http://') || url.startsWith('https://')) { 113 | return url; 114 | } 115 | return `${BASE_PATH}${url.replace(/^\/+/, '')}`; 116 | } 117 | 118 | /** 119 | * Checks for existing caches and their versions 120 | * @returns {Promise} Cache status information 121 | */ 122 | async function checkCacheVersion() { 123 | const keys = await caches.keys(); 124 | console.log("All cache keys:", keys); 125 | 126 | // Find any existing DumbTerm cache - support both formats: DUMBTERM_PWA_CACHE_V* and DUMBTERM_CACHE_V* 127 | const existingCache = keys.find(key => key === CACHE_NAME) || 128 | keys.find(key => key.startsWith('DUMBTERM_CACHE_V')) || 129 | keys.find(key => key.startsWith('DUMBTERM_PWA_CACHE_V')); 130 | 131 | console.log(`Found existing cache: ${existingCache || 'none'}`); 132 | 133 | // Extract version from cache name 134 | let existingVersion = null; 135 | if (existingCache) { 136 | // Try to extract version from different cache name formats 137 | const versionMatch = existingCache.match(/.*_V(.+)$/) || existingCache.match(/DUMBTERM_CACHE_(.+)$/); 138 | existingVersion = versionMatch && versionMatch[1] ? versionMatch[1] : null; 139 | console.log(`Extracted version from cache name: ${existingVersion || 'none'}`); 140 | } 141 | 142 | // Check if current version cache exists 143 | const currentCacheExists = keys.includes(CACHE_NAME); 144 | 145 | // Check for old versions - be flexible with the cache naming format 146 | const oldCaches = keys.filter(key => 147 | key !== CACHE_NAME && (key.startsWith('DUMBTERM_') && 148 | ((key.includes('_CACHE_') || key.includes('PWA_CACHE')) && key.includes('V'))) 149 | ); 150 | 151 | console.log( 152 | `Cache check: existingVersion=${existingVersion}, ` + 153 | `currentCacheExists=${currentCacheExists}, hasOldVersions=${oldCaches.length > 0}` 154 | ); 155 | 156 | return { 157 | currentCacheExists, 158 | oldCaches, 159 | existingVersion, 160 | isFirstInstall: !existingCache 161 | }; 162 | } 163 | 164 | /** 165 | * Determines if an update notification should be shown 166 | * @param {string} currentVersion - Current installed version 167 | * @param {string} newVersion - New available version 168 | * @returns {boolean} True if update notification should be shown 169 | */ 170 | function shouldShowUpdateNotification(currentVersion, newVersion) { 171 | console.log(`Checking if update notification should be shown: current=${currentVersion}, new=${newVersion}`); 172 | 173 | // Simple case: if we have both versions and they're different, show the notification 174 | const shouldShow = ( 175 | // Both versions must be valid 176 | currentVersion && newVersion && 177 | // Versions must be different 178 | currentVersion !== newVersion && 179 | // If new version is default, don't show update 180 | newVersion !== "1.0.0" 181 | ); 182 | 183 | console.log(`Should show update notification: ${shouldShow}`); 184 | return shouldShow; 185 | } 186 | 187 | /** 188 | * Notifies connected clients about version status 189 | * @returns {Promise} 190 | */ 191 | async function notifyClients() { 192 | const { existingVersion } = await checkCacheVersion(); 193 | console.log(`Notifying clients - existingVersion: ${existingVersion}, CACHE_VERSION: ${CACHE_VERSION}`); 194 | 195 | const clients = await self.clients.matchAll(); 196 | console.log(`Found ${clients.length} clients to notify`); 197 | 198 | clients.forEach(client => { 199 | // Always inform about current version 200 | client.postMessage({ 201 | type: 'CACHE_VERSION_INFO', 202 | currentVersion: existingVersion || CACHE_VERSION 203 | }); 204 | 205 | // Check if update notification should be shown 206 | if (shouldShowUpdateNotification(existingVersion, CACHE_VERSION)) { 207 | console.log(`Sending update notification to client: current=${existingVersion}, new=${CACHE_VERSION}`); 208 | 209 | // Wait a brief moment before sending to ensure client is ready 210 | setTimeout(() => { 211 | client.postMessage({ 212 | type: 'UPDATE_AVAILABLE', 213 | currentVersion: existingVersion, 214 | newVersion: CACHE_VERSION, 215 | cacheName: CACHE_NAME 216 | }); 217 | }, 500); // Increased timeout for better reliability 218 | } 219 | }); 220 | } 221 | 222 | /** 223 | * Handles initial setup for service worker caching 224 | * @returns {Promise} 225 | */ 226 | async function preload() { 227 | console.log("Preparing to install web app cache"); 228 | 229 | // Check cache status 230 | const { currentCacheExists, existingVersion, isFirstInstall } = await checkCacheVersion(); 231 | 232 | // If current version cache already exists, no need to reinstall 233 | if (currentCacheExists) { 234 | console.log(`Cache ${CACHE_NAME} already exists, using existing cache`); 235 | await notifyClients(); // Still check if we need to notify about updates 236 | return; 237 | } 238 | 239 | // For updates (not first-time installations), notify but don't update automatically 240 | if (!isFirstInstall && existingVersion !== CACHE_VERSION) { 241 | console.log(`New version ${CACHE_VERSION} available (current: ${existingVersion})`); 242 | await notifyClients(); 243 | return; 244 | } 245 | 246 | // First-time installation case - no existing cache 247 | if (isFirstInstall) { 248 | console.log(`First-time installation: creating cache with version ${CACHE_VERSION}`); 249 | await installCache(true); // Pass true to indicate first installation 250 | } 251 | } 252 | 253 | /** 254 | * Installs or updates the service worker cache 255 | * @param {boolean} isFirstInstall - Whether this is a first-time installation 256 | * @returns {Promise} 257 | */ 258 | async function installCache(isFirstInstall = false) { 259 | console.log(`${isFirstInstall ? 'First-time installation' : 'Updating'} cache to version ${CACHE_VERSION}`); 260 | const cache = await caches.open(CACHE_NAME); 261 | 262 | try { 263 | // Clear old caches 264 | const { oldCaches } = await checkCacheVersion(); 265 | if (oldCaches.length > 0) { 266 | console.log(`Deleting old caches: ${oldCaches}`); 267 | await Promise.all( 268 | oldCaches.map(key => caches.delete(key)) 269 | ); 270 | } 271 | 272 | // Fetch and cache assets 273 | console.log("Fetching asset manifest..."); 274 | const response = await fetch(getAssetPath("assets/asset-manifest.json")); 275 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 276 | 277 | const assets = await response.json(); 278 | const processedAssets = assets.map(asset => getAssetPath(asset)); 279 | ASSETS_TO_CACHE.push(...processedAssets); 280 | 281 | console.log("Caching assets:", ASSETS_TO_CACHE); 282 | await cache.addAll(ASSETS_TO_CACHE); 283 | console.log("Assets cached successfully"); 284 | 285 | // Notify clients 286 | self.clients.matchAll().then(clients => { 287 | clients.forEach(client => { 288 | if (isFirstInstall) { 289 | // For first-time installations, just update the version display 290 | client.postMessage({ 291 | type: 'CACHE_VERSION_INFO', 292 | currentVersion: CACHE_VERSION, 293 | cacheName: CACHE_NAME 294 | }); 295 | } else { 296 | // For updates, notify about completion and update version display 297 | client.postMessage({ 298 | type: 'UPDATE_COMPLETE', 299 | version: CACHE_VERSION, 300 | cacheName: CACHE_NAME, 301 | success: true 302 | }); 303 | 304 | // Also send specific version info message to maintain compatibility 305 | client.postMessage({ 306 | type: 'CACHE_VERSION_INFO', 307 | currentVersion: CACHE_VERSION, 308 | cacheName: CACHE_NAME 309 | }); 310 | } 311 | }); 312 | }); 313 | } catch (error) { 314 | console.error("Failed to cache assets:", error); 315 | 316 | // Notify clients of failure 317 | self.clients.matchAll().then(clients => { 318 | clients.forEach(client => { 319 | client.postMessage({ 320 | type: 'UPDATE_COMPLETE', 321 | version: CACHE_VERSION, 322 | cacheName: CACHE_NAME, 323 | success: false, 324 | error: error.message 325 | }); 326 | 327 | // Still send version info for UI consistency 328 | client.postMessage({ 329 | type: 'CACHE_VERSION_INFO', 330 | currentVersion: CACHE_VERSION, 331 | cacheName: CACHE_NAME 332 | }); 333 | }); 334 | }); 335 | } 336 | } 337 | 338 | // Service Worker event listeners 339 | self.addEventListener("install", event => { 340 | console.log("Service Worker installing..."); 341 | 342 | event.waitUntil( 343 | // Initialize version first, then skip waiting to activate immediately 344 | initializeVersion() 345 | .then(() => self.skipWaiting()) 346 | .catch(error => { 347 | console.error("Error during service worker installation:", error); 348 | }) 349 | ); 350 | }); 351 | 352 | self.addEventListener("activate", event => { 353 | console.log("Service Worker activating..."); 354 | 355 | event.waitUntil( 356 | Promise.resolve() 357 | .then(async () => { 358 | // Take control of all clients 359 | await self.clients.claim(); 360 | console.log("Service worker has claimed clients"); 361 | 362 | // Check cache status 363 | const { isFirstInstall, existingVersion } = await checkCacheVersion(); 364 | 365 | console.log(`Activation - isFirstInstall: ${isFirstInstall}, existingVersion: ${existingVersion}, CACHE_VERSION: ${CACHE_VERSION}`); 366 | 367 | // First-time install: set up cache immediately 368 | if (isFirstInstall) { 369 | console.log("First-time installation detected, setting up cache"); 370 | await preload(); 371 | } else { 372 | // Existing installation: check for updates and notify clients 373 | console.log("Existing installation detected, checking for updates"); 374 | 375 | // Refresh CACHE_VERSION from config if needed 376 | if (CACHE_VERSION === "1.0.0" || !existingVersion) { 377 | try { 378 | const appConfig = await getAppConfig(); 379 | if (appConfig && appConfig.version) { 380 | CACHE_VERSION = appConfig.version; 381 | CACHE_NAME = appConfig.cacheName || `DUMBTERM_CACHE_V${CACHE_VERSION}`; 382 | console.log(`Updated version from config: ${CACHE_VERSION}`); 383 | } 384 | } catch (error) { 385 | console.error("Error getting config version during activation:", error); 386 | } 387 | } 388 | 389 | // Always notify clients on activation 390 | await notifyClients(); 391 | 392 | // If versions differ, call preload to handle update logic 393 | if (existingVersion !== CACHE_VERSION) { 394 | console.log(`Version difference detected on activation: existing=${existingVersion}, new=${CACHE_VERSION}`); 395 | await preload(); 396 | } 397 | } 398 | }) 399 | .catch(error => { 400 | console.error("Error during service worker activation:", error); 401 | }) 402 | ); 403 | }); 404 | 405 | self.addEventListener("fetch", event => { 406 | event.respondWith( 407 | caches.match(event.request) 408 | .then(cachedResponse => { 409 | // Return cached response if found 410 | if (cachedResponse) { 411 | return cachedResponse; 412 | } 413 | 414 | // Otherwise fetch from network 415 | return fetch(event.request.clone()) 416 | .then(response => { 417 | // Don't cache if not a valid response 418 | if (!response || response.status !== 200 || response.type !== 'basic') { 419 | return response; 420 | } 421 | 422 | // Clone the response and cache it 423 | const responseToCache = response.clone(); 424 | caches.open(CACHE_NAME) 425 | .then(cache => { 426 | cache.put(event.request, responseToCache); 427 | }); 428 | 429 | return response; 430 | }); 431 | }) 432 | .catch(() => { 433 | // Return fallback for navigation requests 434 | if (event.request.mode === 'navigate') { 435 | return caches.match(getAssetPath('index.html')); 436 | } 437 | return new Response('Network error happened', { 438 | status: 408, 439 | headers: { 'Content-Type': 'text/plain' }, 440 | }); 441 | }) 442 | ); 443 | }); 444 | 445 | // Message handling 446 | self.addEventListener('message', async event => { 447 | const { data } = event; 448 | 449 | if (!data || !data.type) return; 450 | 451 | console.log(`Received message from client: ${data.type}`, data); 452 | 453 | switch (data.type) { 454 | case 'SET_VERSION': 455 | await handleSetVersion(data.version, data.cacheName); 456 | break; 457 | 458 | case 'GET_VERSION': 459 | await handleGetVersion(event); 460 | break; 461 | 462 | case 'PERFORM_UPDATE': 463 | await handlePerformUpdate(); 464 | break; 465 | 466 | case 'CHECK_FOR_UPDATES': 467 | // Force a check for updates and notify clients 468 | console.log("Client requested update check"); 469 | setTimeout(() => notifyClients(), 500); 470 | break; 471 | } 472 | }); 473 | 474 | /** 475 | * Handles SET_VERSION messages from the client 476 | * @param {string} version - Version sent from client 477 | * @param {string} cacheName - Optional cache name from client 478 | */ 479 | async function handleSetVersion(version, cacheName) { 480 | if (!version) return; 481 | 482 | console.log(`Received version from client: ${version}${cacheName ? `, cacheName: ${cacheName}` : ''}`); 483 | 484 | // Check current cache status 485 | const { existingVersion, isFirstInstall } = await checkCacheVersion(); 486 | 487 | // Only update if non-default version and different from current 488 | if (version !== CACHE_VERSION) { 489 | const previousVersion = CACHE_VERSION; 490 | CACHE_VERSION = version; 491 | 492 | // Use cacheName if provided directly in the message 493 | if (cacheName) { 494 | CACHE_NAME = cacheName; 495 | console.log(`Using cacheName from message: ${CACHE_NAME}`); 496 | } else { 497 | // Try to get cache name from config as fallback 498 | try { 499 | const appConfig = await getAppConfig(); 500 | CACHE_NAME = appConfig?.cacheName || `DUMBTERM_CACHE_V${CACHE_VERSION}`; 501 | } catch (error) { 502 | // Fall back to constructed cache name on error 503 | CACHE_NAME = `DUMBTERM_CACHE_V${CACHE_VERSION}`; 504 | console.log(`Error getting config, using constructed cache name: ${CACHE_NAME}`); 505 | } 506 | } 507 | 508 | console.log(`Updated cache version from ${previousVersion} to ${CACHE_VERSION}`); 509 | 510 | // For first-time installations, install cache immediately 511 | if (isFirstInstall) { 512 | console.log("First-time installation with SET_VERSION - installing cache"); 513 | await installCache(true); 514 | } 515 | // For meaningful version changes, check if update is needed 516 | else if (previousVersion !== "1.0.0") { 517 | console.log(`Version change detected: ${previousVersion} → ${CACHE_VERSION}`); 518 | await preload(); 519 | } 520 | // Otherwise just update the UI 521 | else { 522 | await notifyClients(); 523 | } 524 | } else { 525 | console.log(`No version change needed, current: ${CACHE_VERSION}`); 526 | await notifyClients(); 527 | } 528 | } 529 | 530 | /** 531 | * Handles GET_VERSION messages from the client 532 | * @param {MessageEvent} event - Original message event with ports 533 | */ 534 | async function handleGetVersion(event) { 535 | // Get cache status 536 | const { existingVersion, isFirstInstall } = await checkCacheVersion(); 537 | console.log(`Handling GET_VERSION - existingVersion: ${existingVersion}, isFirstInstall: ${isFirstInstall}, current CACHE_VERSION: ${CACHE_VERSION}`); 538 | 539 | // If default version, try to get from config 540 | if (CACHE_VERSION === "1.0.0") { 541 | try { 542 | const appConfig = await getAppConfig(); 543 | if (appConfig) { 544 | if (appConfig.cacheName) { 545 | console.log(`Using cacheName from config: ${appConfig.cacheName}`); 546 | CACHE_NAME = appConfig.cacheName; 547 | 548 | // Try to extract version from cacheName 549 | const versionMatch = CACHE_NAME.match(/.*_V(.+)$/) || CACHE_NAME.match(/DUMBTERM_CACHE_(.+)$/); 550 | if (versionMatch && versionMatch[1]) { 551 | CACHE_VERSION = versionMatch[1]; 552 | console.log(`Extracted version from cacheName: ${CACHE_VERSION}`); 553 | } 554 | } 555 | 556 | if (appConfig.version && (CACHE_VERSION === "1.0.0" || !appConfig.cacheName)) { 557 | CACHE_VERSION = appConfig.version; 558 | if (!appConfig.cacheName) { 559 | CACHE_NAME = `DUMBTERM_CACHE_V${CACHE_VERSION}`; 560 | } 561 | console.log(`Using version from config: ${CACHE_VERSION}`); 562 | } 563 | } 564 | } catch (error) { 565 | console.error("Error getting config version:", error); 566 | } 567 | } 568 | 569 | // For first-time installs, report the current version to avoid update prompts 570 | const versionToReport = isFirstInstall ? CACHE_VERSION : (existingVersion || CACHE_VERSION); 571 | 572 | console.log(`Reporting versions - current: ${versionToReport}, new: ${CACHE_VERSION}`); 573 | 574 | // Create a consistent update available message based on version comparison 575 | const updateMessage = { 576 | currentVersion: versionToReport, 577 | newVersion: CACHE_VERSION, 578 | cacheName: CACHE_NAME 579 | }; 580 | 581 | // Send version via message channel if available 582 | if (event.ports && event.ports[0]) { 583 | event.ports[0].postMessage(updateMessage); 584 | console.log("Sent version via message port"); 585 | } 586 | 587 | // Also broadcast to all clients 588 | const clients = await self.clients.matchAll(); 589 | console.log(`Broadcasting to ${clients.length} clients`); 590 | 591 | clients.forEach(client => { 592 | // Send version info 593 | client.postMessage({ 594 | type: 'CACHE_VERSION_INFO', 595 | currentVersion: versionToReport, 596 | cacheName: CACHE_NAME 597 | }); 598 | 599 | // Also send update notification if needed 600 | if (shouldShowUpdateNotification(versionToReport, CACHE_VERSION)) { 601 | console.log(`Sending direct update notification from GET_VERSION handler`); 602 | setTimeout(() => { 603 | client.postMessage({ 604 | type: 'UPDATE_AVAILABLE', 605 | currentVersion: versionToReport, 606 | newVersion: CACHE_VERSION, 607 | cacheName: CACHE_NAME 608 | }); 609 | }, 750); // Use a longer timeout to ensure client is ready 610 | } 611 | }); 612 | } 613 | 614 | /** 615 | * Handles PERFORM_UPDATE messages from the client 616 | */ 617 | async function handlePerformUpdate() { 618 | console.log("User confirmed update, installing cache"); 619 | await installCache(false); 620 | } -------------------------------------------------------------------------------- /public/managers/serviceWorker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ServiceWorkerManager.js 3 | * Manages the registration and communication with the service worker 4 | * for version checking, updates, and caching. 5 | */ 6 | 7 | export default class ServiceWorkerManager { 8 | constructor() { 9 | // Tracking variables 10 | this.updateTimeout = null; 11 | this.updateNotificationActive = false; 12 | this.lastCheckedVersionPair = null; 13 | this._receivedCacheName = null; 14 | 15 | // Initialize with basePath from config 16 | this.basePath = window.appConfig?.basePath || ''; 17 | } 18 | 19 | /** 20 | * Initialize the service worker manager 21 | * @param {Function} updateVersionDisplayCallback - Callback to update the version display in the UI 22 | */ 23 | initialize(updateVersionDisplayCallback) { 24 | if (!("serviceWorker" in navigator)) { 25 | // If service workers are not supported, still update the version display with app config 26 | if (updateVersionDisplayCallback && window.appConfig?.version) { 27 | updateVersionDisplayCallback(window.appConfig.version); 28 | } 29 | return; 30 | } 31 | 32 | this.updateVersionDisplayCallback = updateVersionDisplayCallback; 33 | 34 | // Set up the service worker 35 | this.registerServiceWorker(); 36 | this.setupServiceWorkerMessageHandlers(); 37 | this.setupPeriodicVersionCheck(); 38 | 39 | // Immediately check for version in cache and update display, don't wait for service worker 40 | this.getCurrentCacheVersion().then(cacheInfo => { 41 | if (cacheInfo.version && this.updateVersionDisplayCallback) { 42 | this.updateVersionDisplayCallback(cacheInfo.version); 43 | } else { 44 | // Do a more comprehensive check through the service worker 45 | this.checkVersion(); 46 | } 47 | }).catch(() => { 48 | // If there's an error getting the cache version, fall back to the regular check 49 | this.checkVersion(); 50 | }); 51 | 52 | // Set a timeout to ensure the version display is updated even if service worker is delayed 53 | setTimeout(() => { 54 | if (this.updateVersionDisplayCallback) { 55 | const versionDisplay = document.getElementById('version-display'); 56 | if (versionDisplay && versionDisplay.classList.contains('loading')) { 57 | console.log('Version display still loading after timeout, using app config version'); 58 | this.updateVersionDisplayCallback(window.appConfig?.version || ""); 59 | } 60 | } 61 | }, 2000); // 2 second timeout 62 | } 63 | 64 | /** 65 | * Registers the service worker 66 | */ 67 | registerServiceWorker() { 68 | // Set version display to loading state until we have version info 69 | if (this.updateVersionDisplayCallback) { 70 | this.updateVersionDisplayCallback(null, true); // true indicates loading state 71 | } 72 | 73 | // Add cache-busting version parameter to service worker URL 74 | const swVersion = window.appConfig?.version || new Date().getTime(); 75 | const swUrl = this.joinPath(`service-worker.js?v=${swVersion}`); 76 | console.log(`Registering service worker with version param: ${swVersion}`); 77 | 78 | navigator.serviceWorker.register(swUrl) 79 | .then(registration => { 80 | console.log("Service Worker registered:", registration.scope); 81 | 82 | // Check if a service worker is already controlling the page 83 | const isFirstLoad = !navigator.serviceWorker.controller; 84 | 85 | // Send the app version to any active service worker 86 | if (window.appConfig?.version) { 87 | this.sendVersionToServiceWorker(window.appConfig.version); 88 | } 89 | 90 | // On first load or hard reload, we need to be more proactive 91 | if (isFirstLoad) { 92 | console.log("First load or hard reload detected - checking cache directly"); 93 | // First immediately try to get version from cache 94 | this.getCurrentCacheVersion().then(cacheInfo => { 95 | if (cacheInfo.version && this.updateVersionDisplayCallback) { 96 | console.log(`Updating version display with cache version: ${cacheInfo.version}`); 97 | this.updateVersionDisplayCallback(cacheInfo.version); 98 | } else if (window.appConfig?.version && this.updateVersionDisplayCallback) { 99 | // If no cache version, fall back to app config version 100 | console.log(`No cache version found, using app config version: ${window.appConfig.version}`); 101 | this.updateVersionDisplayCallback(window.appConfig.version); 102 | } 103 | }).catch(error => { 104 | console.error("Error getting cache version:", error); 105 | // Fall back to app config version on error 106 | if (window.appConfig?.version && this.updateVersionDisplayCallback) { 107 | this.updateVersionDisplayCallback(window.appConfig.version); 108 | } 109 | }); 110 | } else { 111 | // Normal load (not hard reload), use the regular check 112 | this.checkVersion(); 113 | } 114 | }) 115 | .catch(error => console.error("Service Worker registration failed:", error)); 116 | } 117 | 118 | /** 119 | * Sets up message event listeners for the service worker 120 | */ 121 | setupServiceWorkerMessageHandlers() { 122 | navigator.serviceWorker.addEventListener('message', event => { 123 | if (!event.data || !event.data.type) return; 124 | 125 | switch (event.data.type) { 126 | case 'UPDATE_AVAILABLE': 127 | // Store cacheName if provided in the message 128 | if (event.data.cacheName) { 129 | console.log(`Received cacheName in update message: ${event.data.cacheName}`); 130 | // Use it as a local cached value for later use 131 | this._receivedCacheName = event.data.cacheName; 132 | } 133 | this.showUpdateNotification(event.data.currentVersion, event.data.newVersion); 134 | break; 135 | 136 | case 'UPDATE_COMPLETE': 137 | this.handleUpdateComplete(event.data); 138 | break; 139 | 140 | case 'CACHE_VERSION_INFO': 141 | // Store cacheName if provided 142 | if (event.data.cacheName) { 143 | console.log(`Received cacheName in version info: ${event.data.cacheName}`); 144 | this._receivedCacheName = event.data.cacheName; 145 | } 146 | // Update the version display 147 | if (this.updateVersionDisplayCallback) { 148 | this.updateVersionDisplayCallback(event.data.currentVersion); 149 | } 150 | break; 151 | } 152 | }); 153 | } 154 | 155 | /** 156 | * Sets up periodic version checking when page is visible 157 | */ 158 | setupPeriodicVersionCheck() { 159 | // Check for updates when the page becomes visible 160 | document.addEventListener('visibilitychange', () => { 161 | if (document.visibilityState === 'visible' && navigator.serviceWorker.controller) { 162 | this.checkVersion(); 163 | } 164 | }); 165 | 166 | // Also check periodically for version updates (every hour) 167 | setInterval(() => { 168 | if (navigator.serviceWorker.controller) { 169 | this.checkVersion(); 170 | } 171 | }, 60 * 60 * 1000); 172 | } 173 | 174 | /** 175 | * Sends version information to all possible service worker targets 176 | * @param {string} version - Version to send 177 | */ 178 | sendVersionToServiceWorker(version) { 179 | if (!version) return; 180 | 181 | // Message to send with version and optional cacheName 182 | const message = { 183 | type: 'SET_VERSION', 184 | version: version 185 | }; 186 | 187 | // Include cacheName if available in config 188 | if (window.appConfig?.cacheName) { 189 | message.cacheName = window.appConfig.cacheName; 190 | console.log(`Including cacheName in message to service worker: ${window.appConfig.cacheName}`); 191 | } 192 | 193 | // Try to send to the controller if it exists 194 | if (navigator.serviceWorker.controller) { 195 | navigator.serviceWorker.controller.postMessage(message); 196 | } 197 | 198 | // Also send through the ready promise to ensure we reach the active worker 199 | navigator.serviceWorker.ready.then(registration => { 200 | if (registration.active) { 201 | registration.active.postMessage(message); 202 | } 203 | if (registration.waiting) { 204 | registration.waiting.postMessage(message); 205 | } 206 | }); 207 | } 208 | 209 | /** 210 | * Checks for version updates by querying the service worker 211 | * Uses direct cache access first, then falls back to service worker messaging 212 | */ 213 | checkVersion() { 214 | // Skip check if an update notification is already active 215 | if (this.updateNotificationActive) { 216 | console.log("Update notification already active, skipping version check"); 217 | return; 218 | } 219 | 220 | // First try to get the version directly from cache 221 | this.getCurrentCacheVersion().then(cacheInfo => { 222 | // Check for update directly if we have version info from cache 223 | const configVersion = window.appConfig?.version; 224 | 225 | if (cacheInfo.version && configVersion && 226 | !cacheInfo.isFirstInstall && 227 | this.shouldShowUpdateNotification(cacheInfo.version, configVersion)) { 228 | 229 | this.showUpdateNotification(cacheInfo.version, configVersion); 230 | return; 231 | } 232 | 233 | // Only query service worker if we have an active controller 234 | if (navigator.serviceWorker.controller) { 235 | const messageChannel = new MessageChannel(); 236 | 237 | messageChannel.port1.onmessage = event => { 238 | console.log("Received version info from service worker:", event.data); 239 | 240 | const currentVersion = cacheInfo.version || event.data.currentVersion; 241 | const newVersion = configVersion || event.data.newVersion; 242 | 243 | if (this.shouldShowUpdateNotification(currentVersion, newVersion) && !cacheInfo.isFirstInstall) { 244 | this.showUpdateNotification(currentVersion, newVersion); 245 | } 246 | }; 247 | 248 | navigator.serviceWorker.controller.postMessage( 249 | { type: 'GET_VERSION' }, 250 | [messageChannel.port2] 251 | ); 252 | } 253 | }).catch(error => { 254 | console.error("Error checking version:", error); 255 | }); 256 | } 257 | 258 | /** 259 | * Gets the currently installed cache version directly 260 | * @returns {Promise} Cache version information 261 | */ 262 | async getCurrentCacheVersion() { 263 | try { 264 | console.log('Attempting to get cache version directly from cache storage'); 265 | 266 | // First check if we already have a cached version from service worker messages 267 | if (this._receivedCacheName) { 268 | console.log(`Using previously received cacheName: ${this._receivedCacheName}`); 269 | // Try to extract version from the cached cacheName 270 | const versionMatch = this._receivedCacheName.match(/.*_V(.+)$/) || 271 | this._receivedCacheName.match(/DUMBTERM_CACHE_(.+)$/); 272 | 273 | if (versionMatch && versionMatch[1]) { 274 | const version = versionMatch[1]; 275 | console.log(`Extracted version from cached cacheName: ${version}`); 276 | return { 277 | version: version, 278 | cacheName: this._receivedCacheName, 279 | isFirstInstall: false 280 | }; 281 | } 282 | } 283 | 284 | // Get version from cache storage 285 | const configCacheName = window.appConfig?.cacheName; 286 | 287 | const cacheKeys = await caches.keys(); 288 | console.log('Available caches:', cacheKeys); 289 | 290 | // Support both formats: DUMBTERM_PWA_CACHE_V* and DUMBTERM_CACHE_* or any from config 291 | const dumbTermCaches = cacheKeys.filter(key => 292 | (configCacheName && key === configCacheName) || 293 | (key.startsWith('DUMBTERM_') && (key.includes('_V') || key.includes('_CACHE_'))) 294 | ); 295 | 296 | if (dumbTermCaches.length === 0) { 297 | console.log('No DumbTerm cache found'); 298 | return { 299 | version: window.appConfig?.version || null, 300 | cacheName: configCacheName || null, 301 | isFirstInstall: true 302 | }; 303 | } 304 | 305 | // If config cacheName exists and is in the list, prioritize it 306 | let latestCache; 307 | if (configCacheName && dumbTermCaches.includes(configCacheName)) { 308 | latestCache = configCacheName; 309 | console.log(`Using config-specified cacheName: ${latestCache}`); 310 | } else { 311 | // Otherwise sort caches by version to get the latest 312 | dumbTermCaches.sort((a, b) => { 313 | // Extract version from different patterns 314 | const getVersion = (cacheName) => { 315 | const vMatch = cacheName.match(/.*_V(.+)$/) || cacheName.match(/DUMBTERM_CACHE_(.+)$/); 316 | return vMatch && vMatch[1] ? vMatch[1] : ''; 317 | }; 318 | const versionA = getVersion(a); 319 | const versionB = getVersion(b); 320 | return versionB.localeCompare(versionA); 321 | }); 322 | latestCache = dumbTermCaches[0]; 323 | console.log(`Selected latest cache: ${latestCache}`); 324 | } 325 | 326 | // Extract version from cacheName 327 | let version; 328 | const versionMatch = latestCache.match(/.*_V(.+)$/) || latestCache.match(/DUMBTERM_CACHE_(.+)$/); 329 | version = versionMatch && versionMatch[1] ? versionMatch[1] : window.appConfig?.version || null; 330 | 331 | console.log(`Found cache version: ${version}, cacheName: ${latestCache}`); 332 | 333 | // Store the cacheName for future use 334 | this._receivedCacheName = latestCache; 335 | 336 | return { 337 | version: version, 338 | cacheName: latestCache, 339 | isFirstInstall: false, 340 | allCaches: dumbTermCaches 341 | }; 342 | } catch (error) { 343 | console.error('Error getting cache version:', error); 344 | 345 | // On error, fall back to app config 346 | const fallbackVersion = window.appConfig?.version || null; 347 | console.log(`Falling back to app config version: ${fallbackVersion}`); 348 | 349 | return { 350 | version: fallbackVersion, 351 | error: error.message, 352 | isFirstInstall: true 353 | }; 354 | } 355 | } 356 | 357 | /** 358 | * Determines if an update notification should be shown 359 | * @param {string} currentVersion - Current version 360 | * @param {string} newVersion - New version 361 | * @returns {boolean} True if notification should be shown 362 | */ 363 | shouldShowUpdateNotification(currentVersion, newVersion) { 364 | const result = ( 365 | // Both versions must exist 366 | currentVersion && newVersion && 367 | // Versions must be different 368 | currentVersion !== newVersion && 369 | // New version shouldn't be default 370 | newVersion !== "1.0.0" 371 | ); 372 | 373 | console.log(`Client shouldShowUpdateNotification check: current=${currentVersion}, new=${newVersion}, result=${result}`); 374 | return result; 375 | } 376 | 377 | /** 378 | * Shows a notification when updates are available 379 | * @param {string} currentVersion - Current installed version 380 | * @param {string} newVersion - New available version 381 | */ 382 | showUpdateNotification(currentVersion, newVersion) { 383 | console.log(`Showing update notification: current=${currentVersion}, new=${newVersion}`); 384 | 385 | // Validate versions before showing 386 | if (!this.shouldShowUpdateNotification(currentVersion, newVersion)) { 387 | console.log("Update notification skipped - invalid version comparison"); 388 | return; 389 | } 390 | 391 | // Check if we're already showing a notification for this version pair 392 | const versionPair = `${currentVersion}-${newVersion}`; 393 | if (this.updateNotificationActive && this.lastCheckedVersionPair === versionPair) { 394 | console.log("Update notification already active for this version pair, skipping duplicate"); 395 | return; 396 | } 397 | 398 | // Clear any existing update timeout to prevent race conditions 399 | if (this.updateTimeout) { 400 | clearTimeout(this.updateTimeout); 401 | this.updateTimeout = null; 402 | } 403 | 404 | // Update tracking variables 405 | this.updateNotificationActive = true; 406 | this.lastCheckedVersionPair = versionPair; 407 | 408 | // Remove any existing notifications 409 | const existingNotifications = document.querySelectorAll('.update-notification'); 410 | existingNotifications.forEach(notification => notification.remove()); 411 | 412 | // Create new notification 413 | const updateNotification = document.createElement('div'); 414 | updateNotification.className = 'update-notification'; 415 | updateNotification.innerHTML = ` 416 |

New version v${newVersion} available!

417 |

Current: v${currentVersion}

418 | 419 | 420 | `; 421 | document.body.appendChild(updateNotification); 422 | 423 | // Set up buttons 424 | document.getElementById('update-now').addEventListener('click', () => { 425 | this.handleUpdateRequest(updateNotification); 426 | }); 427 | 428 | document.getElementById('update-later').addEventListener('click', () => { 429 | updateNotification.remove(); 430 | this.updateNotificationActive = false; 431 | }); 432 | 433 | // Log to console for debugging 434 | console.log(`Update notification displayed for ${currentVersion} → ${newVersion}`); 435 | } 436 | 437 | /** 438 | * Handles a user request to update the application 439 | * @param {HTMLElement} notification - The notification element 440 | */ 441 | handleUpdateRequest(notification) { 442 | // Show loading state 443 | const updateButton = notification.querySelector('#update-now'); 444 | updateButton.innerHTML = 'Updating...'; 445 | updateButton.disabled = true; 446 | 447 | // Also update version display to show loading state 448 | if (this.updateVersionDisplayCallback) { 449 | this.updateVersionDisplayCallback(null, true); // true indicates loading state 450 | } 451 | 452 | // Set a timeout to handle cases where the update might hang 453 | if (this.updateTimeout) clearTimeout(this.updateTimeout); 454 | this.updateTimeout = setTimeout(() => { 455 | // Remove any notifications 456 | const existingNotifications = document.querySelectorAll('.update-notification'); 457 | existingNotifications.forEach(notification => notification.remove()); 458 | 459 | // Show timeout error 460 | const errorNotification = document.createElement('div'); 461 | errorNotification.className = 'update-notification error'; 462 | errorNotification.innerHTML = ` 463 |

Update timed out. Please try again.

464 | 465 | `; 466 | document.body.appendChild(errorNotification); 467 | setTimeout(() => { 468 | errorNotification.remove(); 469 | this.updateNotificationActive = false; 470 | }, 5000); 471 | 472 | // Restore version display 473 | if (this.updateVersionDisplayCallback) { 474 | this.updateVersionDisplayCallback(); 475 | } 476 | }, 30000); // 30 second timeout 477 | 478 | if (navigator.serviceWorker.controller) { 479 | // Request the update via service worker 480 | navigator.serviceWorker.controller.postMessage({ type: 'PERFORM_UPDATE' }); 481 | } else { 482 | // No service worker controller, try to force reload 483 | console.log("No service worker controller, forcing page reload"); 484 | this.updateNotificationActive = false; 485 | window.location.reload(true); 486 | } 487 | } 488 | 489 | /** 490 | * Handles update completion messages 491 | * @param {Object} data - Update completion data 492 | */ 493 | handleUpdateComplete(data) { 494 | // Clear any pending timeout 495 | if (this.updateTimeout) { 496 | clearTimeout(this.updateTimeout); 497 | this.updateTimeout = null; 498 | } 499 | 500 | // Reset notification tracking state 501 | this.updateNotificationActive = false; 502 | this.lastCheckedVersionPair = null; 503 | 504 | // Remove any existing update notifications 505 | const existingNotifications = document.querySelectorAll('.update-notification'); 506 | existingNotifications.forEach(notification => notification.remove()); 507 | 508 | if (data.success === false) { 509 | // Show error notification 510 | const errorNotification = document.createElement('div'); 511 | errorNotification.className = 'update-notification error'; 512 | errorNotification.innerHTML = ` 513 |

Update failed: ${data.error || 'Unknown error'}

514 | 515 | `; 516 | document.body.appendChild(errorNotification); 517 | setTimeout(() => errorNotification.remove(), 5000); 518 | } else { 519 | // Store cacheName if available for future use 520 | if (data.cacheName) { 521 | console.log(`Caching received cacheName in update complete: ${data.cacheName}`); 522 | this._receivedCacheName = data.cacheName; 523 | } 524 | 525 | // First update the version display if version is available 526 | if (data.version && this.updateVersionDisplayCallback) { 527 | this.updateVersionDisplayCallback(data.version); 528 | 529 | // Small delay to ensure UI updates before reload 530 | setTimeout(() => { 531 | window.location.reload(); 532 | }, 100); 533 | } else { 534 | // Fall back to immediate reload if no version info 535 | window.location.reload(); 536 | } 537 | } 538 | } 539 | 540 | /** 541 | * Explicitly checks for updates and forces the service worker to look for new versions 542 | * This can be called when user manually wants to check for updates 543 | */ 544 | checkForUpdates() { 545 | console.log("Manual update check requested"); 546 | 547 | if (!navigator.serviceWorker.controller) { 548 | console.log("No active service worker, cannot check for updates"); 549 | return false; 550 | } 551 | 552 | // Clear any existing notification first 553 | if (this.updateNotificationActive) { 554 | const existingNotifications = document.querySelectorAll('.update-notification'); 555 | existingNotifications.forEach(notification => notification.remove()); 556 | this.updateNotificationActive = false; 557 | this.lastCheckedVersionPair = null; 558 | } 559 | 560 | // First, update the service worker with the current app version 561 | if (window.appConfig?.version) { 562 | this.sendVersionToServiceWorker(window.appConfig.version); 563 | } 564 | 565 | // Then explicitly request an update check 566 | console.log("Sending CHECK_FOR_UPDATES message to service worker"); 567 | setTimeout(() => { 568 | navigator.serviceWorker.controller.postMessage({ 569 | type: 'CHECK_FOR_UPDATES' 570 | }); 571 | }, 300); // Small delay to ensure version is processed first 572 | 573 | return true; 574 | } 575 | 576 | /** 577 | * Helper function to join paths with base path 578 | * @param {string} path - Path to join with base path 579 | * @returns {string} Joined path 580 | */ 581 | joinPath(path) { 582 | // Remove any leading slash from path and trailing slash from basePath 583 | const cleanPath = path.replace(/^\/+/, ''); 584 | const cleanBase = this.basePath.replace(/\/+$/, ''); 585 | 586 | // Join with single slash 587 | return cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath; 588 | } 589 | } 590 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | /* NerdFont Integration */ 2 | @font-face { 3 | font-family: 'FiraCode Nerd Font'; 4 | src: url('/assets/fonts/FiraCodeNerdFontMono-Regular.ttf') format('truetype'); 5 | font-weight: 400; 6 | font-style: normal; 7 | font-display: block; /* Block render text until font loads */ 8 | } 9 | 10 | @font-face { 11 | font-family: 'FiraCode Nerd Font'; 12 | src: url('/assets/fonts/FiraCodeNerdFontMono-Medium.ttf') format('truetype'); 13 | font-weight: 500; 14 | font-style: normal; 15 | font-display: block; 16 | } 17 | 18 | @font-face { 19 | font-family: 'FiraCode Nerd Font'; 20 | src: url('/assets/fonts/FiraCodeNerdFontMono-Bold.ttf') format('truetype'); 21 | font-weight: 700; 22 | font-style: normal; 23 | font-display: block; 24 | } 25 | 26 | /* Other weights can use swap strategy since they're less critical */ 27 | @font-face { 28 | font-family: 'FiraCode Nerd Font'; 29 | src: url('/assets/fonts/FiraCodeNerdFontMono-Light.ttf') format('truetype'); 30 | font-weight: 300; 31 | font-style: normal; 32 | font-display: swap; 33 | } 34 | 35 | @font-face { 36 | font-family: 'FiraCode Nerd Font'; 37 | src: url('/assets/fonts/FiraCodeNerdFontMono-SemiBold.ttf') format('truetype'); 38 | font-weight: 600; 39 | font-style: normal; 40 | font-display: swap; 41 | } 42 | 43 | @font-face { 44 | font-family: 'FiraCode Nerd Font'; 45 | src: url('/assets/fonts/FiraCodeNerdFontMono-Retina.ttf') format('truetype'); 46 | font-weight: 450; 47 | font-style: normal; 48 | font-display: swap; 49 | } 50 | 51 | @font-face { 52 | font-family: 'FiraCode Nerd Font'; 53 | src: url('/assets/fonts/FiraCodeNerdFont-Regular.ttf') format('truetype'); 54 | font-weight: normal; 55 | font-style: italic; 56 | font-display: swap; 57 | } 58 | 59 | :root, [data-theme="light"] { 60 | /* Light theme variables */ 61 | --primary: #2196F3; 62 | --primary-hover: #1976D2; 63 | --secondary-color: #e5e7eb; 64 | --background: #f5f5f5; 65 | --container: white; 66 | --text: #333; 67 | --border: #ccc; 68 | --shadow: 0 2px 4px rgba(0,0,0,0.1); 69 | --transition: 0.2s ease; 70 | --terminal-tabs-container: #f0f0f0; 71 | --terminal-bg: #FCFCFC; 72 | --terminal-active:rgba(33, 149, 243, 0.8); 73 | --terminal-text: #2C2C2C; 74 | --terminal-cursor: gray; 75 | --terminal-selection: rgba(33, 149, 243, 0.3); 76 | --terminal-border: rgba(0, 0, 0, 0.1); 77 | --btn-default:rgba(231, 229, 229, 0.5); 78 | --terminal-font: 'FiraCode Nerd Font', Menlo, Monaco, 'Courier New', monospace; 79 | --tooltip-bg: rgba(0, 0, 0, 0.7); 80 | --tooltip-text: #fff; 81 | --accent-color: var(--primary); 82 | --input-element-transition: background-color 0.2s ease; 83 | } 84 | 85 | [data-theme="dark"] { 86 | --background: #1a1a1a; 87 | --container: #2d2d2d; 88 | --secondary-color: #374151; 89 | --text: white; 90 | --border: #404040; 91 | --shadow: 0 2px 4px rgba(0,0,0,0.2); 92 | --terminal-tabs-container: #101010; 93 | --terminal-bg: #1F1F1F; 94 | --terminal-active:rgba(33, 149, 243, 0.5); 95 | --terminal-text: #E0E0E0; 96 | --terminal-cursor: gray; 97 | --terminal-selection: rgba(33, 149, 243, 0.2); 98 | --terminal-border: rgba(255, 255, 255, 0.1); 99 | --btn-default:rgba(231, 229, 229, 0.3); 100 | --tooltip-bg: rgba(255, 255, 255, 0.85); 101 | --tooltip-text: #333; 102 | --accent-color: var(--primary); 103 | } 104 | 105 | /* Base styles */ 106 | * { 107 | margin: 0; 108 | padding: 0; 109 | box-sizing: border-box; 110 | } 111 | 112 | body { 113 | margin: 0; 114 | padding: 0; 115 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 116 | background: var(--background); 117 | color: var(--text); 118 | transition: background var(--transition), color var(--transition); 119 | min-height: 100vh; 120 | height: 100vh; 121 | overflow: hidden; 122 | display: flex; 123 | flex-direction: column; 124 | } 125 | 126 | /* Main content */ 127 | main { 128 | flex: 1; 129 | display: flex; 130 | align-items: center; 131 | justify-content: center; 132 | padding: 1rem; 133 | height: 100%; 134 | overflow: hidden; 135 | } 136 | 137 | /* Container styling */ 138 | .container { 139 | position: relative; 140 | background: var(--container); 141 | padding: 2rem 1rem 1.5rem; 142 | border-radius: 12px; 143 | box-shadow: var(--shadow); 144 | width: calc(100% - 2rem); 145 | max-width: 85%; 146 | min-height: 0; 147 | height: calc(100vh - 2rem); 148 | max-height: calc(100vh - 2rem); 149 | transition: background var(--transition), box-shadow var(--transition); 150 | margin: 1rem auto; 151 | display: flex; 152 | flex-direction: column; 153 | overflow: hidden; 154 | } 155 | 156 | .header-top { 157 | display: flex; 158 | justify-content: center; 159 | align-items: center; 160 | position: absolute; 161 | left: 1rem; 162 | right: 1rem; 163 | top: 0; 164 | background: none; 165 | border: none; 166 | cursor: pointer; 167 | /* padding: 0.5rem; */ 168 | color: var(--text-color); 169 | border-radius: 8px; 170 | transition: var(--input-element-transition); 171 | font-size: 1.5rem; 172 | } 173 | 174 | .header-right { 175 | display: flex; 176 | justify-content: center; 177 | align-items: center; 178 | position: absolute; 179 | right: 1rem; 180 | top: 1rem; 181 | background: none; 182 | border: none; 183 | cursor: pointer; 184 | padding: 0.5rem; 185 | color: var(--text-color); 186 | border-radius: 8px; 187 | transition: var(--input-element-transition); 188 | font-size: 1.5rem; 189 | } 190 | 191 | #terminal { 192 | flex: 1; 193 | padding: 0.75rem; 194 | background: var(--terminal-bg); 195 | border-radius: 12px; 196 | overflow: hidden; 197 | margin-top: 0.5rem; 198 | min-height: 0; 199 | height: calc(100% - 3rem); 200 | max-height: calc(100% - 3rem); 201 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 202 | border: 1px solid var(--terminal-border); 203 | } 204 | 205 | .xterm { 206 | height: 100%; 207 | padding: 0.5rem; 208 | font-family: var(--terminal-font) !important; 209 | font-feature-settings: "liga" 1, "calt" 1; /* Enable ligatures */ 210 | padding-right: 15px; /* Match scrollbar width */ 211 | padding-bottom: 5px; 212 | position: relative; 213 | top: -5px; /* Move terminal content up */ 214 | } 215 | 216 | .xterm-viewport { 217 | overflow-y: auto !important; 218 | -webkit-font-smoothing: antialiased; 219 | -moz-osx-font-smoothing: grayscale; 220 | } 221 | 222 | .xterm-viewport::-webkit-scrollbar { 223 | width: 10px; 224 | display: block; 225 | } 226 | 227 | .xterm-viewport::-webkit-scrollbar-track { 228 | background: var(--terminal-bg); 229 | } 230 | 231 | .xterm-viewport::-webkit-scrollbar-thumb { 232 | background: var(--terminal-border); 233 | border-radius: 5px; 234 | border: 2px solid var(--terminal-bg); 235 | } 236 | 237 | .xterm-viewport::-webkit-scrollbar-thumb:hover { 238 | background: rgba(255, 255, 255, 0.3); 239 | } 240 | 241 | /* Form styling */ 242 | form { 243 | position: relative; 244 | background: var(--container); 245 | padding: 3rem 1.5rem 2rem; 246 | border-radius: 12px; 247 | box-shadow: var(--shadow); 248 | width: calc(100% - 2rem); 249 | max-width: 400px; 250 | transition: background var(--transition), box-shadow var(--transition); 251 | text-align: center; 252 | margin: 1rem auto; 253 | transform: translateY(-10%); 254 | } 255 | 256 | /* header-right buttons */ 257 | #themeToggle, #logoutBtn, #search-open { 258 | /* position: absolute; */ 259 | top: 1rem; 260 | right: 1rem; 261 | background: none; 262 | border: none; 263 | cursor: pointer; 264 | padding: 0.5rem; 265 | width: 40px; 266 | height: 40px; 267 | display: flex; 268 | align-items: center; 269 | justify-content: center; 270 | border-radius: 50%; 271 | transition: background-color var(--transition); 272 | } 273 | 274 | #themeToggle:hover, #logoutBtn:hover, #search-open:hover { 275 | background: rgba(128, 128, 128, 0.1); 276 | } 277 | 278 | #themeToggle svg, #logoutBtn svg, #search-open svg { 279 | width: 24px; 280 | height: 24px; 281 | stroke: var(--text); 282 | fill: none; 283 | stroke-width: 2; 284 | stroke-linecap: round; 285 | stroke-linejoin: round; 286 | transition: stroke var(--transition); 287 | } 288 | 289 | [data-theme="light"] .moon { 290 | display: block; 291 | } 292 | 293 | [data-theme="light"] .sun { 294 | display: none; 295 | } 296 | 297 | [data-theme="dark"] .moon { 298 | display: none; 299 | } 300 | 301 | [data-theme="dark"] .sun { 302 | display: block; 303 | } 304 | 305 | /* Headings */ 306 | h1 { 307 | font-size: 2rem; 308 | font-weight: 500; 309 | margin-bottom: 0.75rem; 310 | color: var(--text); 311 | text-align: center; 312 | } 313 | 314 | h2 { 315 | font-size: 0.875rem; 316 | font-weight: 400; 317 | margin-bottom: 2rem; 318 | color: var(--text); 319 | opacity: 0.7; 320 | text-align: center; 321 | } 322 | 323 | /* PIN input styling */ 324 | .pin-input-container { 325 | display: flex; 326 | flex-wrap: wrap; 327 | gap: 0.75rem; 328 | justify-content: center; 329 | margin: 2rem 0; 330 | padding: 0 0.5rem; 331 | } 332 | 333 | .pin-input-container input.pin-input { 334 | width: 44px; 335 | height: 44px; 336 | text-align: center; 337 | font-size: 1.25rem; 338 | font-weight: 500; 339 | border: 2px solid var(--border); 340 | border-radius: 12px; 341 | background: var(--container); 342 | color: var(--text); 343 | transition: all var(--transition); 344 | -webkit-appearance: none; 345 | -moz-appearance: textfield; 346 | } 347 | 348 | .pin-input-container input.pin-input::-webkit-outer-spin-button, 349 | .pin-input-container input.pin-input::-webkit-inner-spin-button { 350 | -webkit-appearance: none; 351 | margin: 0; 352 | } 353 | 354 | .pin-input-container input.pin-input:focus { 355 | outline: none; 356 | border-color: var(--primary); 357 | box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.15); 358 | } 359 | 360 | .pin-input-container input.pin-input.has-value { 361 | border-color: var(--primary); 362 | background-color: var(--primary); 363 | color: white; 364 | } 365 | 366 | .pin-error { 367 | color: #ff4444; 368 | font-size: 0.9rem; 369 | margin-top: 0.5rem; 370 | text-align: center; 371 | display: none; 372 | opacity: 0; 373 | transform: translateY(-10px); 374 | transition: opacity var(--transition), transform var(--transition); 375 | } 376 | 377 | .pin-error[aria-hidden="false"] { 378 | display: block; 379 | opacity: 1; 380 | transform: translateY(0); 381 | } 382 | 383 | /* Credit styling */ 384 | .dumbware-credit { 385 | position: fixed; 386 | bottom: 14px; 387 | width: 100%; 388 | text-align: center; 389 | font-size: 0.75em; 390 | opacity: 0.5; 391 | padding: 8px; 392 | z-index: 100; 393 | pointer-events: none; 394 | margin: 0; 395 | font-family: 'FiraCode Nerd Font', monospace; 396 | } 397 | 398 | .dumbware-credit a { 399 | color: inherit; 400 | text-decoration: none; 401 | pointer-events: auto; 402 | } 403 | 404 | .dumbware-credit a:hover { 405 | text-decoration: underline; 406 | opacity: 0.8; 407 | } 408 | 409 | .version-display { 410 | font-size: 0.75rem; 411 | opacity: 0.8; 412 | color: var(--text); 413 | } 414 | 415 | .version-display.loading::after { 416 | content: ""; 417 | display: inline-block; 418 | width: 8px; 419 | height: 8px; 420 | margin-left: 5px; 421 | border: 2px solid var(--text); 422 | border-right-color: transparent; 423 | border-radius: 50%; 424 | animation: spin 1s linear infinite; 425 | } 426 | 427 | @keyframes spin { 428 | 0% { transform: rotate(0deg); } 429 | 100% { transform: rotate(360deg); } 430 | } 431 | 432 | .tooltip { 433 | position: absolute; 434 | background-color: var(--secondary-color); 435 | color: var(--text-color); 436 | padding: 5px 10px; 437 | border-radius: 4px; 438 | font-size: 12px; 439 | visibility: hidden; 440 | opacity: 0; 441 | transition: opacity 0.3s; 442 | z-index: 1000; /* Ensure it's above other elements */ 443 | } 444 | 445 | .tooltip.show { 446 | visibility: visible; 447 | opacity: 1; 448 | } 449 | 450 | /* Terminal search styling */ 451 | .terminal-search-container { 452 | position: absolute; 453 | top: 1.4rem; 454 | right: 7rem; 455 | display: flex; 456 | align-items: center; 457 | gap: 0.5rem; 458 | background: var(--background); 459 | padding: 0.5rem; 460 | border-radius: 6px; 461 | box-shadow: var(--shadow); 462 | z-index: 1000; 463 | width: 30%; 464 | height: 3rem; 465 | } 466 | 467 | .terminal-search-container input { 468 | background: var(--terminal-bg); 469 | color: var(--terminal-text); 470 | border: 1px solid var(--terminal-border); 471 | border-radius: 4px; 472 | padding: 0.25rem 0.5rem; 473 | font-family: inherit; 474 | font-size: 14px; 475 | width: calc(100% - 2rem); 476 | height: 100%; 477 | } 478 | 479 | .terminal-search-container input:focus { 480 | outline: none; 481 | border-color: var(--primary); 482 | } 483 | 484 | .search-buttons { 485 | display: flex; 486 | gap: 0.25rem; 487 | } 488 | 489 | .search-buttons button { 490 | background: var(--terminal-bg); 491 | color: var(--terminal-text); 492 | border: 1px solid var(--terminal-border); 493 | border-radius: 4px; 494 | width: 24px; 495 | height: 24px; 496 | display: flex; 497 | align-items: center; 498 | justify-content: center; 499 | cursor: pointer; 500 | font-size: 14px; 501 | padding: 0; 502 | } 503 | 504 | .search-buttons button:hover { 505 | background: var(--terminal-border); 506 | } 507 | 508 | /* Terminal tabs styling */ 509 | .terminal-tabs { 510 | display: flex; 511 | align-items: center; 512 | gap: 0.5rem; 513 | height: 42px; 514 | position: relative; 515 | } 516 | 517 | /* Show scrollbar only when needed */ 518 | .tab-list::-webkit-scrollbar { 519 | height: 6px; 520 | display: block; 521 | } 522 | 523 | .tab-list::-webkit-scrollbar-track { 524 | background: transparent; 525 | } 526 | 527 | .tab-list::-webkit-scrollbar-thumb { 528 | background: var(--terminal-border); 529 | border-radius: 3px; 530 | } 531 | 532 | .tab-list::-webkit-scrollbar-thumb:hover { 533 | background: var(--primary); 534 | } 535 | 536 | /* Firefox scrollbar */ 537 | .tab-list { 538 | scrollbar-width: thin; 539 | scrollbar-color: var(--terminal-border) transparent; 540 | } 541 | 542 | /* Hide scrollbar when not needed (no overflow) */ 543 | .tab-list.no-scroll::-webkit-scrollbar { 544 | display: none; 545 | } 546 | .tab-list.no-scroll { 547 | scrollbar-width: none; 548 | } 549 | 550 | .tab-list { 551 | display: flex; 552 | align-items: center; /* Ensure tabs are centered vertically */ 553 | gap: 0.25rem; 554 | flex: 1; 555 | min-width: 0; 556 | overflow-x: auto; 557 | padding-bottom: 6px; /* Space for scrollbar */ 558 | } 559 | 560 | .terminal-tab { 561 | display: flex; 562 | align-items: center; 563 | gap: 0.5rem; 564 | padding: 0.25rem 1rem; 565 | background: var(--terminal-bg); 566 | color: var(--terminal-text); 567 | border: 1px solid var(--terminal-border); 568 | border-radius: 6px; 569 | cursor: grab; 570 | font-size: 0.875rem; 571 | white-space: nowrap; 572 | transition: transform 0.2s ease, opacity 0.2s ease, background-color 0.1s ease; 573 | will-change: transform, opacity; 574 | min-width: 120px; 575 | max-width: 250px; 576 | height: 28px; /* Match the height of the add button */ 577 | user-select: none; 578 | position: relative; /* Added to ensure proper positioning of the tooltip */ 579 | z-index: 1; 580 | } 581 | 582 | /* Add this to handle text overflow */ 583 | .terminal-tab span { 584 | overflow: hidden; 585 | text-overflow: ellipsis; 586 | white-space: nowrap; 587 | flex: 1; 588 | } 589 | 590 | .terminal-tab.active { 591 | background: var(--terminal-active); 592 | border-color: var(--primary); 593 | color: white; 594 | } 595 | 596 | .terminal-tab:hover:not(.active) { 597 | background: var(--terminal-border); 598 | } 599 | 600 | .terminal-tab .close-tab { 601 | padding: 2px; 602 | margin-left: auto; 603 | border-radius: 4px; 604 | opacity: 0.7; 605 | transition: opacity var(--transition); 606 | display: flex; 607 | align-items: center; 608 | justify-content: center; 609 | width: 16px; 610 | height: 16px; 611 | padding: 0; 612 | margin-left: auto; 613 | border: none; 614 | background: none; 615 | color: inherit; 616 | cursor: pointer; 617 | opacity: 0.6; 618 | position: relative; 619 | display: inline-flex; 620 | align-items: center; 621 | justify-content: center; 622 | } 623 | 624 | .terminal-tab .close-tab::before, 625 | .terminal-tab .close-tab::after { 626 | content: ''; 627 | position: absolute; 628 | width: 10px; 629 | height: 1.5px; 630 | background-color: currentColor; 631 | border-radius: 1px; 632 | transition: transform var(--transition); 633 | } 634 | 635 | .terminal-tab .close-tab::before { 636 | transform: rotate(45deg); 637 | } 638 | 639 | .terminal-tab .close-tab::after { 640 | transform: rotate(-45deg); 641 | } 642 | 643 | .terminal-tab .close-tab:hover { 644 | opacity: 1; 645 | } 646 | 647 | .terminal-tab .close-tab:hover::before { 648 | transform: rotate(45deg) scale(1.2); 649 | } 650 | 651 | .terminal-tab .close-tab:hover::after { 652 | transform: rotate(-45deg) scale(1.2); 653 | } 654 | 655 | .terminal-tab.dragging { 656 | opacity: 0.7; 657 | z-index: 10; 658 | } 659 | 660 | .terminal-tab.drag-over { 661 | background-color: var(--tab-hover-bg); 662 | } 663 | 664 | .terminal-tab.just-moved { 665 | animation: slideIn 0.25s ease-out forwards; 666 | } 667 | 668 | @keyframes slideIn { 669 | 0% { transform: translateX(15px); opacity: 0.7; } 670 | 100% { transform: translateX(0); opacity: 1; } 671 | } 672 | 673 | /* Style for drop indicator */ 674 | .drop-indicator { 675 | position: absolute; 676 | background: var(--accent); 677 | width: 2px; 678 | top: 0; 679 | bottom: 0; 680 | transition: left 0.15s ease; 681 | z-index: 5; 682 | } 683 | 684 | .ghost-tab { 685 | background: var(--bg-color); 686 | border: 1px solid var(--border-color); 687 | border-radius: 4px; 688 | padding: 4px 8px; 689 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 690 | } 691 | 692 | .new-tab-button { 693 | background: var(--btn-default); 694 | color: var(--terminal-text); 695 | border: 1px solid var(--terminal-border); 696 | border-radius: 6px; 697 | width: 28px; 698 | height: 28px; 699 | display: flex; 700 | position: relative; 701 | bottom: 3px; 702 | align-items: center; 703 | justify-content: center; 704 | cursor: pointer; 705 | font-size: 20px; 706 | line-height: 1; 707 | padding: 0; 708 | margin-right: 8px; 709 | transition: all var(--transition); 710 | flex-shrink: 0; 711 | align-self: center; /* Ensure button is centered vertically */ 712 | } 713 | 714 | .new-tab-button:hover { 715 | background: var(--primary); 716 | border-color: var(--primary); 717 | color: white; 718 | } 719 | 720 | .terminals-container { 721 | position: relative; 722 | flex: 1; 723 | min-height: 0; 724 | height: calc(100% - 8px); /* Adjust for the margin we added */ 725 | margin-top: 0; 726 | border-radius: 12px; /* Round all corners */ 727 | overflow: hidden; 728 | display: flex; 729 | flex-direction: column; 730 | } 731 | 732 | .terminals-container > div { 733 | position: absolute; 734 | top: 0; 735 | left: 0; 736 | right: 0; 737 | bottom: 0; 738 | background: var(--terminal-bg); 739 | border-radius: 12px; 740 | border: 1px solid var(--terminal-border); 741 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 742 | opacity: 0; 743 | pointer-events: none; 744 | transition: opacity var(--transition); 745 | padding: 0.75rem; 746 | display: flex; 747 | flex-direction: column; 748 | /* overflow: hidden; */ 749 | } 750 | 751 | .terminals-container > div.active { 752 | opacity: 1; 753 | pointer-events: auto; 754 | } 755 | 756 | .xterm-viewport, 757 | .xterm-screen, 758 | .xterm-link-layer { 759 | /* overflow: hidden !important; */ 760 | position: absolute; 761 | inset: 0; 762 | top: -5px; /* Move these components up to match */ 763 | } 764 | 765 | .xterm-link-layer { 766 | pointer-events: none; 767 | } 768 | 769 | .xterm-link-layer > div { 770 | pointer-events: auto; 771 | } 772 | 773 | /* Tab renaming styles */ 774 | .rename-input { 775 | background: transparent; 776 | border: none; 777 | outline: none; 778 | color: inherit; 779 | font-size: inherit; 780 | font-family: inherit; 781 | width: 100%; 782 | max-width: 200px; 783 | padding: 0 2px; 784 | margin: 0; 785 | border-bottom: 1px solid var(--accent-color); 786 | } 787 | 788 | /* Tabs container styling */ 789 | .tabs-container { 790 | background: var(--terminal-tabs-container); 791 | border-radius: 12px; /* Rounded corners all around */ 792 | padding: 8px 12px 2px; 793 | border: 1px solid var(--terminal-border); 794 | margin-bottom: 0.5rem; /* Add space between tabs and terminal */ 795 | position: relative; 796 | z-index: 5; 797 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* Slight shadow for depth */ 798 | } 799 | 800 | /* Update notification styling */ 801 | .update-notification { 802 | position: fixed; 803 | bottom: 2rem; 804 | right: 2rem; 805 | background: var(--container); 806 | padding: 1rem 1.5rem; 807 | border-radius: 8px; 808 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); 809 | z-index: 9999; 810 | display: flex; 811 | flex-direction: column; 812 | gap: 1rem; 813 | border-left: 4px solid var(--primary); 814 | max-width: 300px; 815 | animation: slideInNotification 0.3s ease forwards; 816 | } 817 | 818 | .update-notification.error { 819 | border-left-color: #ff4444; 820 | } 821 | 822 | .update-notification.error button { 823 | background: var(--secondary-color); 824 | color: var(--text); 825 | } 826 | 827 | @keyframes slideInNotification { 828 | from { 829 | transform: translateX(100%); 830 | opacity: 0; 831 | } 832 | to { 833 | transform: translateX(0); 834 | opacity: 1; 835 | } 836 | } 837 | 838 | .update-notification p { 839 | margin: 0; 840 | font-weight: 500; 841 | } 842 | 843 | .update-notification button { 844 | background: var(--primary); 845 | color: white; 846 | border: none; 847 | border-radius: 4px; 848 | padding: 0.5rem 1rem; 849 | cursor: pointer; 850 | font-weight: 500; 851 | transition: all 0.2s; 852 | } 853 | 854 | .update-notification button:hover { 855 | background: var(--primary-hover); 856 | } 857 | 858 | .update-notification button:disabled { 859 | opacity: 0.7; 860 | cursor: wait; 861 | } 862 | 863 | .update-notification button.secondary { 864 | background: transparent; 865 | border: 1px solid var(--border); 866 | color: var(--text); 867 | } 868 | 869 | .update-notification button.secondary:hover { 870 | background: var(--btn-default); 871 | } 872 | 873 | .demo-banner { 874 | background: linear-gradient(135deg, #ff6b6b 0%, #ff8383 100%); 875 | color: white; 876 | text-align: center; 877 | padding: 0.5rem 1rem; 878 | margin: 0 auto 1rem auto; 879 | font-weight: 500; 880 | font-size: 0.9rem; 881 | animation: pulse 2s infinite; 882 | box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2); 883 | border-radius: 0 0 8px 8px; 884 | position: relative; 885 | z-index: 5; 886 | width: fit-content; 887 | max-width: 90%; 888 | backdrop-filter: blur(8px); 889 | /* border: 1px solid rgba(255, 255, 255, 0.1); */ 890 | } 891 | 892 | .demo-banner span { 893 | display: flex; 894 | align-items: center; 895 | justify-content: center; 896 | gap: 0.5rem; 897 | white-space: nowrap; 898 | } 899 | 900 | @keyframes pulse { 901 | 0% { opacity: 1; } 902 | 50% { opacity: 0.70; } 903 | 100% { opacity: 1; } 904 | } 905 | 906 | /* Responsive adjustments */ 907 | @media (max-width: 1000px) { 908 | .container { 909 | width: calc(100%); 910 | max-width: 100%; 911 | /* padding: 1.5rem 0.75rem 1rem; 912 | margin: 0.5rem auto; */ 913 | } 914 | 915 | #terminal { 916 | padding: 0.5rem; 917 | min-height: 250px; 918 | margin-top: 0.5rem; 919 | } 920 | 921 | #siteTitle { 922 | font-size: 1.5rem; 923 | } 924 | 925 | .header-right { 926 | position: absolute; 927 | float: right; 928 | padding: 0; 929 | margin: 0; 930 | top: 0.2rem; 931 | } 932 | 933 | .terminal-search-container { 934 | top: 1rem; 935 | right: 6rem; 936 | } 937 | 938 | .terminal-search-container { 939 | width: calc(100% - 2rem); 940 | height: 3rem; 941 | top: 0.7rem; 942 | right: 1rem; 943 | } 944 | 945 | .terminal-search-container input { 946 | width: calc(100% - 2rem); 947 | height: 100%; 948 | } 949 | 950 | .terminal-search-container button { 951 | width: 2rem; 952 | height: 2rem; 953 | } 954 | 955 | .demo-banner { 956 | padding: 0.5rem; 957 | margin: 0 auto 1rem auto; 958 | font-size: 0.8rem; 959 | } 960 | } 961 | 962 | /* Responsive adjustments for update notification */ 963 | @media (max-width: 600px) { 964 | main { 965 | padding: 0; 966 | } 967 | 968 | .container { 969 | width: 100%; 970 | padding-left: 0.5rem; 971 | padding-right: 0.5rem; 972 | } 973 | 974 | .terminal-search-container { 975 | width: calc(100% - 1rem); 976 | right: 0.5rem; 977 | } 978 | 979 | .update-notification { 980 | bottom: 1rem; 981 | right: 1rem; 982 | left: 1rem; 983 | max-width: none; 984 | } 985 | } 986 | 987 | @media (max-width: 450px) { 988 | form { 989 | padding: 3rem 0.75rem 1.5rem; 990 | width: calc(100% - 1rem); 991 | margin: 0.5rem auto; 992 | } 993 | 994 | .pin-input-container { 995 | gap: 0.4rem; 996 | padding: 0; 997 | } 998 | 999 | .pin-input-container input.pin-input { 1000 | width: 36px; 1001 | height: 36px; 1002 | font-size: 1rem; 1003 | border-radius: 8px; 1004 | } 1005 | 1006 | h1 { 1007 | font-size: 1.75rem; 1008 | } 1009 | 1010 | #terminal { 1011 | padding: 0.25rem; 1012 | min-height: 200px; 1013 | } 1014 | } 1015 | 1016 | @media (max-width: 320px) { 1017 | .pin-input-container input.pin-input { 1018 | width: 32px; 1019 | height: 32px; 1020 | } 1021 | } --------------------------------------------------------------------------------