├── .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 |
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 |
28 |
54 |
55 |
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 |
46 |
47 |
81 |
DumbTerm
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
94 |
95 |
96 |
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 | 
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 | 
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` |  |
225 | | Bracketed Segments | `starship preset bracketed-segments -o ~/.config/starship.toml` |  |
226 | | Plain Text Symbols | `starship preset plain-text-symbols -o ~/.config/starship.toml` |  |
227 | | No Runtime Versions | `starship preset no-runtime-versions -o ~/.config/starship.toml` |  |
228 | | No Empty Icons | `starship preset no-empty-icons -o ~/.config/starship.toml` |  |
229 | | Pure Prompt | `starship preset pure-preset -o ~/.config/starship.toml` |  |
230 | | Pastel Powerline | `starship preset pastel-powerline -o ~/.config/starship.toml` |  |
231 | | Tokyo Night `(DumbTerm Default with mods)` | `starship preset tokyo-night -o ~/.config/starship.toml` |  |
232 | | Gruvbox Rainbow | `starship preset gruvbox-rainbow -o ~/.config/starship.toml` |  |
233 | | Jetpack | `starship preset jetpack -o ~/.config/starship.toml` |  |
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 |
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