From 1f26427328322cb1a83447bfb907c72a633f4205 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:14:56 -0700 Subject: [PATCH 01/11] security(P0): close unauthenticated RCE chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local FastAPI server previously exposed macro create/execute over the network with no authentication while bound to 0.0.0.0, and macro `app` commands ran via subprocess with shell=True — an unauthenticated RCE for anyone on the LAN (and, via the relay, the internet). - Mandatory per-install auth token (secrets.token_urlsafe) required on all /api/* routes and the /ws handshake; timing-safe hmac.compare_digest. PWA shell + /static assets remain public to bootstrap. - Token auto-generated and persisted in settings.json (now chmod 0600). - Bind host is configurable (web.allow_lan); token is the security boundary. - macro `app` launch no longer uses shell=True — commands are tokenized with shlex and exec'd directly, killing ;/&&/|/$() shell chaining. - Relay client injects the local token and only forwards /api/* paths, and no longer forwards attacker-supplied headers to the local server. - Fixed path traversal in /api/image/{path} (realpath-confined to macro_images). - GUI embeds the token in the LAN URL/QR; PWA reads it and sends it on API, WebSocket, and image requests. Co-Authored-By: Claude Opus 4.8 (1M context) --- gui/main_window.py | 19 +++++++++++---- gui/settings_manager.py | 24 +++++++++++++++++++ macro_manager.py | 13 +++++++++-- relay_client.py | 25 +++++++++++++++++--- web/js/app.js | 27 +++++++++++++++++++++- web_server.py | 51 +++++++++++++++++++++++++++++++++-------- 6 files changed, 139 insertions(+), 20 deletions(-) diff --git a/gui/main_window.py b/gui/main_window.py index 4e18a5d..09f3718 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -146,7 +146,12 @@ class MainWindow(QMainWindow): self.macro_manager = MacroManager(data_file, images_dir, app_dir) # Initialize web server - self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT) + token = self.settings_manager.get_web_auth_token() + host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1" + self.web_server = WebServer( + self.macro_manager, app_dir, DEFAULT_PORT, + auth_token=token, host=host + ) self.server_thread = None # Relay client (initialized later if enabled) @@ -450,7 +455,10 @@ class MainWindow(QMainWindow): self.ip_label.setText(full_url) return - # Fall back to local IP + # Fall back to local IP. Append the auth token as a query param so the + # QR/copied URL lets a LAN device authenticate against the API. + token = self.settings_manager.get_web_auth_token() + token_qs = f"?token={token}" if token else "" try: import netifaces for iface in netifaces.interfaces(): @@ -459,11 +467,11 @@ class MainWindow(QMainWindow): for addr in addrs[netifaces.AF_INET]: ip = addr.get('addr', '') if ip and not ip.startswith('127.'): - self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}") + self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}") return except Exception: pass - self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}") + self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}") def copy_url_to_clipboard(self): """Copy the web interface URL to clipboard.""" @@ -703,7 +711,8 @@ class MainWindow(QMainWindow): local_port=DEFAULT_PORT, on_connected=self.on_relay_connected, on_disconnected=self.on_relay_disconnected, - on_session_id=self.on_relay_session_id + on_session_id=self.on_relay_session_id, + local_auth_token=self.settings_manager.get_web_auth_token() ) self.relay_client.start() self.status_bar.showMessage("Connecting to relay server...") diff --git a/gui/settings_manager.py b/gui/settings_manager.py index 613bc13..a0ac3f5 100644 --- a/gui/settings_manager.py +++ b/gui/settings_manager.py @@ -2,6 +2,7 @@ import os import json +import secrets from typing import Any, Optional @@ -12,6 +13,10 @@ DEFAULT_SETTINGS = { "session_id": None, "password": "" }, + "web": { + "auth_token": "", + "allow_lan": True + }, "minimize_to_tray": True } @@ -36,12 +41,23 @@ class SettingsManager: # Merge with defaults to ensure all keys exist self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings) + # Ensure a web auth token exists (this is the security boundary for API access) + if not self.get('web.auth_token'): + token = secrets.token_urlsafe(32) + self.set('web.auth_token', token) + self.save() + def save(self): """Save settings to file.""" try: os.makedirs(os.path.dirname(self.settings_file), exist_ok=True) with open(self.settings_file, 'w', encoding='utf-8') as f: json.dump(self.settings, f, indent=2) + # Best-effort restrict permissions (protects stored relay password + token) + try: + os.chmod(self.settings_file, 0o600) + except OSError: + pass return True except IOError: return False @@ -101,6 +117,14 @@ class SettingsManager: self.set('relay.session_id', session_id) self.save() + def get_web_auth_token(self) -> str: + """Get the web API auth token.""" + return self.get('web.auth_token', '') + + def get_web_allow_lan(self) -> bool: + """Check if LAN (0.0.0.0) binding is allowed.""" + return self.get('web.allow_lan', True) + def get_minimize_to_tray(self) -> bool: """Check if minimize to tray is enabled.""" return self.get('minimize_to_tray', True) diff --git a/macro_manager.py b/macro_manager.py index c770f07..6ece051 100644 --- a/macro_manager.py +++ b/macro_manager.py @@ -3,6 +3,7 @@ import copy import json import os +import shlex import uuid import pyautogui import subprocess @@ -316,10 +317,18 @@ class MacroManager: time.sleep(ms / 1000.0) elif cmd_type == "app": - # Launch application + # Launch application. + # SECURITY: shell=True was removed to kill shell-metacharacter + # injection (no ; && | $() chaining). We tokenize the command + # and exec the program directly without a shell. command = cmd.get("command", "") if command: - subprocess.Popen(command, shell=True) + try: + args = shlex.split(command, posix=(os.name != "nt")) + if args: + subprocess.Popen(args) + except Exception as e: + print(f"Error launching app command: {e}") # Legacy API compatibility methods def add_macro_legacy( diff --git a/relay_client.py b/relay_client.py index da5970a..92ccb13 100644 --- a/relay_client.py +++ b/relay_client.py @@ -20,7 +20,8 @@ class RelayClient: local_port: int = 40000, on_connected: Optional[Callable] = None, on_disconnected: Optional[Callable] = None, - on_session_id: Optional[Callable[[str], None]] = None + on_session_id: Optional[Callable[[str], None]] = None, + local_auth_token: str = "" ): self.relay_url = relay_url.rstrip('/') if not self.relay_url.endswith('/desktop'): @@ -28,6 +29,7 @@ class RelayClient: self.password = password self.session_id = session_id self.local_url = f"http://localhost:{local_port}" + self.local_auth_token = local_auth_token # Callbacks self.on_connected = on_connected @@ -174,16 +176,33 @@ class RelayClient: method = msg.get("method", "GET").upper() path = msg.get("path", "/") body = msg.get("body") - headers = msg.get("headers", {}) + + # Restrict the relay to API routes only. This prevents the relay from + # reaching static/other paths on the local server. + if not path.startswith("/api/"): + await self._ws.send_json({ + "type": "api_response", + "requestId": request_id, + "status": 403, + "body": {"error": "forbidden path"} + }) + return url = f"{self.local_url}{path}" + # Build a fresh headers dict. We do NOT forward attacker-supplied + # headers from the relay; we inject the local auth token ourselves. + sending_body = bool(body) and method in ("POST", "PUT", "PATCH") + headers = {"X-MacroPad-Token": self.local_auth_token} + if sending_body: + headers["Content-Type"] = "application/json" + try: # Forward request to local server async with self._session.request( method, url, - json=body if body and method in ("POST", "PUT", "PATCH") else None, + json=body if sending_body else None, headers=headers ) as response: # Handle binary responses (images) diff --git a/web/js/app.js b/web/js/app.js index b20f7bf..6426e1b 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -12,11 +12,14 @@ class MacroPadApp { this.relayMode = this.detectRelayMode(); this.sessionId = null; this.password = null; + this.localToken = null; this.desktopConnected = true; this.wsAuthenticated = false; if (this.relayMode) { this.initRelayMode(); + } else { + this.initLocalMode(); } this.init(); @@ -51,6 +54,20 @@ class MacroPadApp { console.log('Relay mode enabled, session:', this.sessionId); } + initLocalMode() { + // Get token from URL query param or localStorage (local/LAN mode) + const urlParams = new URLSearchParams(window.location.search); + this.localToken = urlParams.get('token') || localStorage.getItem('macropad_local_token'); + + if (this.localToken) { + // Persist token and strip it from the URL for cleanliness + localStorage.setItem('macropad_local_token', this.localToken); + if (urlParams.has('token')) { + window.history.replaceState({}, '', window.location.pathname); + } + } + } + getApiUrl(path) { if (this.relayMode && this.sessionId) { return `/${this.sessionId}${path}`; @@ -62,6 +79,8 @@ class MacroPadApp { const headers = { 'Content-Type': 'application/json' }; if (this.relayMode && this.password) { headers['X-MacroPad-Password'] = this.password; + } else if (!this.relayMode && this.localToken) { + headers['X-MacroPad-Token'] = this.localToken; } return headers; } @@ -176,6 +195,10 @@ class MacroPadApp { wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`; } else { wsUrl = `${protocol}//${window.location.host}/ws`; + // Append token as query param (WebSocket can't set headers) + if (this.localToken) { + wsUrl += `?token=${encodeURIComponent(this.localToken)}`; + } } try { @@ -311,9 +334,11 @@ class MacroPadApp { let imageSrc = null; if (macro.image_path) { const basePath = this.getApiUrl(`/api/image/${macro.image_path}`); - // Add password as query param for relay mode (img tags can't use headers) + // Add credential as query param since img tags can't use headers if (this.relayMode && this.password) { imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`; + } else if (!this.relayMode && this.localToken) { + imageSrc = `${basePath}?token=${encodeURIComponent(this.localToken)}`; } else { imageSrc = basePath; } diff --git a/web_server.py b/web_server.py index d20bf33..017ca68 100644 --- a/web_server.py +++ b/web_server.py @@ -2,13 +2,14 @@ import os import sys +import hmac import asyncio from typing import List, Optional from contextlib import asynccontextmanager -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from pydantic import BaseModel import uvicorn @@ -83,10 +84,13 @@ class ConnectionManager: class WebServer: """FastAPI-based web server for MacroPad.""" - def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT): + def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT, + auth_token: str = "", host: str = "127.0.0.1"): self.macro_manager = macro_manager self.app_dir = app_dir self.port = port + self.auth_token = auth_token + self.host = host self.app = None self.manager = ConnectionManager() self.server = None @@ -114,6 +118,25 @@ class WebServer: if os.path.exists(images_dir): app.mount("/images", StaticFiles(directory=images_dir), name="images") + # Paths served without a token (PWA shell + static assets) + public_paths = {"/", "/manifest.json", "/service-worker.js"} + + @app.middleware("http") + async def auth_middleware(request: Request, call_next): + """Require a valid auth token for all non-public paths.""" + path = request.url.path + if path in public_paths or path.startswith("/static/"): + return await call_next(request) + + # Fail-open only when no token is configured (shouldn't happen in prod) + if self.auth_token: + provided = request.headers.get("X-MacroPad-Token") \ + or request.query_params.get("token") or "" + if not hmac.compare_digest(provided, self.auth_token): + return JSONResponse({"detail": "Unauthorized"}, status_code=401) + + return await call_next(request) + @app.get("/", response_class=HTMLResponse) async def index(): """Serve the main PWA page.""" @@ -231,15 +254,25 @@ class WebServer: @app.get("/api/image/{image_path:path}") async def get_image(image_path: str): - """Get macro image (legacy compatibility).""" - full_path = os.path.join(self.app_dir, image_path) - if os.path.exists(full_path): - return FileResponse(full_path) + """Get macro image (legacy compatibility, path-traversal safe).""" + images_dir = os.path.join(self.app_dir, "macro_images") + requested = os.path.realpath(os.path.join(self.app_dir, image_path)) + images_real = os.path.realpath(images_dir) + # Only serve files that resolve to inside the macro_images directory + if (os.path.commonpath([requested, images_real]) == images_real + and os.path.isfile(requested)): + return FileResponse(requested) raise HTTPException(status_code=404, detail="Image not found") @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): """WebSocket for real-time updates.""" + # Require a valid token before accepting the connection + if self.auth_token: + provided = websocket.query_params.get("token") or "" + if not hmac.compare_digest(provided, self.auth_token): + await websocket.close(code=1008) + return await self.manager.connect(websocket) try: while True: @@ -260,7 +293,7 @@ class WebServer: config = uvicorn.Config( self.app, - host="0.0.0.0", + host=self.host, port=self.port, log_level="warning", log_config=None # Disable default logging config for PyInstaller compatibility @@ -280,7 +313,7 @@ class WebServer: config = uvicorn.Config( self.app, - host="0.0.0.0", + host=self.host, port=self.port, log_level="warning", log_config=None # Disable default logging config for PyInstaller compatibility From eba5a2a38eac3d62d11fb0169d0881daff0f26d9 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:15:23 -0700 Subject: [PATCH 02/11] security(relay): harden internet-facing relay server Closes the account-takeover chain and related issues found in audit: - WS auth brute-force protection: per-session lockout (authThrottle) + per- socket failure cap that closes the socket (1008); applied to both web-client and desktop auth. Failed auth no longer leaves the socket open for unlimited guesses. - WS upgrades now pass an IP-based rate limiter and Origin allowlist before handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so limiting keys on the real client IP. - /health no longer leaks live session IDs (counts only). - .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps. - Credentials moved out of URLs into the X-MacroPad-Password header; images fetched via header + blob URLs; login stores creds in sessionStorage. - Session creation bounded (max sessions, min password length) and TTL-pruned; session store writes are now atomic (temp+rename) and debounced. - Session IDs lengthened to 12 chars with rejection sampling (no modulo bias). - Enable helmet CSP; restrict CORS to configured origins. - Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep. - Uniform response for unknown session IDs (removes enumeration oracle). Co-Authored-By: Claude Opus 4.8 (1M context) --- macropad-relay/.env.example | 35 +- macropad-relay/DEPLOY.md | 9 +- macropad-relay/package-lock.json | 2484 +++++++++++++++++ macropad-relay/package.json | 3 - macropad-relay/public/app.html | 51 +- macropad-relay/public/login.html | 16 +- macropad-relay/src/config.ts | 42 +- macropad-relay/src/handlers/apiProxy.ts | 6 +- macropad-relay/src/handlers/desktopHandler.ts | 53 +- .../src/handlers/webClientHandler.ts | 41 +- macropad-relay/src/server.ts | 125 +- macropad-relay/src/services/SessionManager.ts | 106 +- macropad-relay/src/utils/authThrottle.ts | 76 + macropad-relay/src/utils/idGenerator.ts | 35 +- 14 files changed, 2998 insertions(+), 84 deletions(-) create mode 100644 macropad-relay/package-lock.json create mode 100644 macropad-relay/src/utils/authThrottle.ts diff --git a/macropad-relay/.env.example b/macropad-relay/.env.example index 45adbaf..b3e2474 100644 --- a/macropad-relay/.env.example +++ b/macropad-relay/.env.example @@ -9,12 +9,37 @@ NODE_ENV=production # Security BCRYPT_ROUNDS=10 -# Session ID length (default: 6 characters) -SESSION_ID_LENGTH=6 +# Session ID length (default: 12 characters) +SESSION_ID_LENGTH=12 -# Rate limiting -RATE_LIMIT_WINDOW_MS=90 -RATE_LIMIT_MAX=1024 +# Minimum password length enforced on session creation +MIN_PASSWORD_LENGTH=6 + +# Maximum number of concurrent sessions +MAX_SESSIONS=1000 + +# Prune sessions unused for this many days +SESSION_TTL_DAYS=90 + +# HTTP rate limiting (15 minute window, 300 requests per window) +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=300 + +# WebSocket upgrade rate limiting (per IP) +WS_UPGRADE_WINDOW_MS=60000 +WS_UPGRADE_MAX=60 + +# Auth brute-force protection +AUTH_MAX_SOCKET_FAILURES=5 +AUTH_LOCKOUT_MAX_ATTEMPTS=10 +AUTH_LOCKOUT_WINDOW_MS=900000 +AUTH_LOCKOUT_DURATION_MS=900000 + +# Allowed origins for CORS + WebSocket (comma-separated). Empty = same-host only. +# ALLOWED_ORIGINS=https://macropad.example.com + +# Trust reverse proxy (number of hops, or true/false). Default: 1 +TRUST_PROXY=1 # WebSocket timeouts (in milliseconds) PING_INTERVAL=30000 diff --git a/macropad-relay/DEPLOY.md b/macropad-relay/DEPLOY.md index 35a997d..e809bd6 100644 --- a/macropad-relay/DEPLOY.md +++ b/macropad-relay/DEPLOY.md @@ -9,9 +9,12 @@ For AnHonestHost cloud-node-container deployment: ```bash cd /home/jknapp/code/macropad/macropad-relay npm install -npm run build +npm run build # required: the build is no longer triggered automatically on install ``` +> Note: the `postinstall` build hook was removed. Always run `npm run build` +> explicitly (locally or in CI) after `npm install` to produce `dist/`. + ### 2. Prepare Deployment Package The build outputs to `dist/` with public files copied. Upload: @@ -84,8 +87,8 @@ Set these in your container configuration: # Test health endpoint curl http://localhost:3000/health -# Should return: -# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]} +# Should return (counts only - no session ids are exposed): +# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23} ``` ## Nginx/Reverse Proxy (for HTTPS) diff --git a/macropad-relay/package-lock.json b/macropad-relay/package-lock.json new file mode 100644 index 0000000..fd3b1d6 --- /dev/null +++ b/macropad-relay/package-lock.json @@ -0,0 +1,2484 @@ +{ + "name": "macropad-relay", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "macropad-relay", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "typescript": "^5.3.2", + "winston": "^3.11.0", + "ws": "^8.14.2" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.16", + "@types/express": "^4.17.21", + "@types/ws": "^8.5.9", + "nodemon": "^3.0.2", + "ts-node": "^10.9.2" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/macropad-relay/package.json b/macropad-relay/package.json index 4247ca2..163c9b0 100644 --- a/macropad-relay/package.json +++ b/macropad-relay/package.json @@ -4,7 +4,6 @@ "description": "Relay server for MacroPad remote access", "main": "dist/index.js", "scripts": { - "postinstall": "npm run build", "build": "tsc", "start": "node dist/index.js", "dev": "nodemon --exec ts-node src/index.ts", @@ -21,7 +20,6 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "typescript": "^5.3.2", - "uuid": "^9.0.0", "winston": "^3.11.0", "ws": "^8.14.2" }, @@ -29,7 +27,6 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.16", "@types/express": "^4.17.21", - "@types/uuid": "^9.0.6", "@types/ws": "^8.5.9", "nodemon": "^3.0.2", "ts-node": "^10.9.2" diff --git a/macropad-relay/public/app.html b/macropad-relay/public/app.html index 526b617..d8fe592 100644 --- a/macropad-relay/public/app.html +++ b/macropad-relay/public/app.html @@ -292,15 +292,12 @@ const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/); this.sessionId = pathMatch ? pathMatch[1] : null; - // Get password from URL or sessionStorage - const urlParams = new URLSearchParams(window.location.search); - this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`); + // Get password from storage (never from the URL). + this.password = sessionStorage.getItem(`macropad_${this.sessionId}`) + || localStorage.getItem(`macropad_${this.sessionId}`); if (this.password) { sessionStorage.setItem(`macropad_${this.sessionId}`, this.password); - if (urlParams.has('auth')) { - window.history.replaceState({}, '', window.location.pathname); - } } this.init(); @@ -374,6 +371,7 @@ handleAuthError() { sessionStorage.removeItem(`macropad_${this.sessionId}`); + localStorage.removeItem(`macropad_${this.sessionId}`); window.location.href = `/${this.sessionId}`; } @@ -467,19 +465,48 @@ } container.innerHTML = entries.map(([id, macro]) => { - // Include password as query param for image authentication - const imageSrc = macro.image_path - ? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}` - : null; + const hasImage = !!macro.image_path; const firstChar = macro.name.charAt(0).toUpperCase(); return `
- ${imageSrc ? `` : ''} -
${firstChar}
+ ${hasImage ? `` : ''} +
${firstChar}
${macro.name}
`; }).join(''); + + // Load macro images via authenticated fetch (header, not URL). + this.loadMacroImages(); + } + + // Fetch each macro image with the password header and display it + // as a blob object URL so the credential never appears in a URL. + async loadMacroImages() { + for (const [id, macro] of Object.entries(this.macros)) { + if (!macro.image_path) continue; + const card = document.querySelector(`[data-macro-id="${id}"]`); + if (!card) continue; + const img = card.querySelector('.macro-image'); + if (!img) continue; + + try { + const response = await fetch( + `/${this.sessionId}/api/image/${macro.image_path}`, + { headers: this.getApiHeaders() } + ); + if (!response.ok) continue; // keep placeholder + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => URL.revokeObjectURL(objectUrl); + img.src = objectUrl; + img.style.display = ''; + const placeholder = img.nextElementSibling; + if (placeholder) placeholder.style.display = 'none'; + } catch (error) { + // Leave the placeholder visible on failure. + } + } } setupEventListeners() { diff --git a/macropad-relay/public/login.html b/macropad-relay/public/login.html index 97b3a27..159ac6e 100644 --- a/macropad-relay/public/login.html +++ b/macropad-relay/public/login.html @@ -169,8 +169,9 @@ let desktopConnected = false; - // Check for saved password - const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`); + // Check for saved password (session first, then "remembered" store) + const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`) + || localStorage.getItem(`macropad_${sessionId}`); if (savedPassword) { passwordInput.value = savedPassword; } @@ -225,13 +226,16 @@ }); if (response.ok) { - // Save password if remember is checked + // Hand the credential to the app via storage (never the URL). + sessionStorage.setItem(`macropad_${sessionId}`, password); if (rememberCheckbox.checked) { - sessionStorage.setItem(`macropad_${sessionId}`, password); + localStorage.setItem(`macropad_${sessionId}`, password); + } else { + localStorage.removeItem(`macropad_${sessionId}`); } - // Redirect to the PWA with password - window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`; + // Redirect to the PWA (credential is read from storage). + window.location.href = `/${sessionId}/app`; } else { const data = await response.json(); errorDiv.textContent = data.error || 'Invalid password'; diff --git a/macropad-relay/src/config.ts b/macropad-relay/src/config.ts index 15fbe60..5669d5a 100644 --- a/macropad-relay/src/config.ts +++ b/macropad-relay/src/config.ts @@ -5,6 +5,14 @@ import path from 'path'; dotenv.config(); +function parseTrustProxy(): boolean | number | string { + const v = process.env.TRUST_PROXY ?? '1'; + if (v === 'false') return false; + if (v === 'true') return true; + const n = Number(v); + return Number.isNaN(n) ? v : n; +} + export const config = { port: parseInt(process.env.PORT || '3000', 10), host: process.env.HOST || '0.0.0.0', @@ -13,19 +21,47 @@ export const config = { dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'), // Session settings - sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10), + sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '12', 10), + + // Maximum number of sessions that may exist at once + maxSessions: parseInt(process.env.MAX_SESSIONS || '1000', 10), + + // Minimum password length enforced when creating a new session + minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH || '6', 10), + + // Sessions unused for this many days are pruned + sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS || '90', 10), // Security bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10), - // Rate limiting + // Rate limiting (HTTP) rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes - rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), + rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '300', 10), + + // WebSocket upgrade rate limiting (per IP) + wsUpgradeWindowMs: parseInt(process.env.WS_UPGRADE_WINDOW_MS || '60000', 10), // 1 minute + wsUpgradeMax: parseInt(process.env.WS_UPGRADE_MAX || '60', 10), // max upgrades per IP per window + + // Auth brute-force protection + authMaxSocketFailures: parseInt(process.env.AUTH_MAX_SOCKET_FAILURES || '5', 10), + authLockoutMaxAttempts: parseInt(process.env.AUTH_LOCKOUT_MAX_ATTEMPTS || '10', 10), + authLockoutWindowMs: parseInt(process.env.AUTH_LOCKOUT_WINDOW_MS || '900000', 10), // 15 minutes + authLockoutDurationMs: parseInt(process.env.AUTH_LOCKOUT_DURATION_MS || '900000', 10), // 15 minutes // WebSocket pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds + // Origin / CORS allowlist (comma-separated). Empty = same-host only. + allowedOrigins: (process.env.ALLOWED_ORIGINS || '') + .split(',') + .map((o) => o.trim()) + .filter((o) => o.length > 0), + + // Trust the X-Forwarded-* headers from a reverse proxy + trustProxy: parseTrustProxy(), + // Logging logLevel: process.env.LOG_LEVEL || 'info', diff --git a/macropad-relay/src/handlers/apiProxy.ts b/macropad-relay/src/handlers/apiProxy.ts index 5acb9d1..fa2f522 100644 --- a/macropad-relay/src/handlers/apiProxy.ts +++ b/macropad-relay/src/handlers/apiProxy.ts @@ -18,9 +18,9 @@ export function createApiProxy( return res.status(404).json({ error: 'Session not found' }); } - // Check password in header or query - const password = req.headers['x-macropad-password'] as string || - req.query.password as string; + // Credentials must be supplied via the header (never the URL/query, + // which leaks into logs, history and referrers). + const password = req.headers['x-macropad-password'] as string; if (!password) { return res.status(401).json({ error: 'Password required' }); diff --git a/macropad-relay/src/handlers/desktopHandler.ts b/macropad-relay/src/handlers/desktopHandler.ts index 39f5880..b6a4c7c 100644 --- a/macropad-relay/src/handlers/desktopHandler.ts +++ b/macropad-relay/src/handlers/desktopHandler.ts @@ -4,6 +4,11 @@ import WebSocket from 'ws'; import { ConnectionManager } from '../services/ConnectionManager'; import { SessionManager } from '../services/SessionManager'; import { logger } from '../utils/logger'; +import { config } from '../config'; +import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle'; + +// Close code 1008 = policy violation (used for auth abuse). +const WS_POLICY_VIOLATION = 1008; interface AuthMessage { type: 'auth'; @@ -35,17 +40,29 @@ export function handleDesktopConnection( sessionManager: SessionManager ): void { let authenticatedSessionId: string | null = null; + // Track failed auth attempts on this individual socket. + let socketAuthFailures = 0; socket.on('message', async (data) => { try { const message: DesktopMessage = JSON.parse(data.toString()); switch (message.type) { - case 'auth': - await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => { + case 'auth': { + const failed = await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => { authenticatedSessionId = sessionId; }); + if (failed) { + socketAuthFailures++; + if (socketAuthFailures >= config.authMaxSocketFailures) { + logger.warn('Desktop exceeded auth attempts, closing socket'); + socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts'); + } + } else { + socketAuthFailures = 0; + } break; + } case 'api_response': if (authenticatedSessionId) { @@ -87,32 +104,46 @@ export function handleDesktopConnection( }); } +// Returns true if the auth attempt failed (so the caller can count it). async function handleAuth( socket: WebSocket, message: AuthMessage, sessionManager: SessionManager, connectionManager: ConnectionManager, setSessionId: (id: string) => void -): Promise { +): Promise { try { let sessionId = message.sessionId; let session; if (sessionId) { + // Reject early if this session id is currently locked out. + const lockRemaining = getLockoutRemaining(sessionId); + if (lockRemaining > 0) { + socket.send(JSON.stringify({ + type: 'auth_response', + success: false, + error: 'Too many failed attempts, try again later', + retryAfterMs: lockRemaining + })); + return true; + } + // Validate existing session const valid = await sessionManager.validatePassword(sessionId, message.password); if (!valid) { + recordFailure(sessionId); socket.send(JSON.stringify({ type: 'auth_response', success: false, error: 'Invalid session ID or password' })); - socket.close(); - return; + return true; } + recordSuccess(sessionId); session = sessionManager.getSession(sessionId); } else { - // Create new session + // Create new session (may throw on limit / short password) session = await sessionManager.createSession(message.password); sessionId = session.id; } @@ -123,8 +154,7 @@ async function handleAuth( success: false, error: 'Failed to create session' })); - socket.close(); - return; + return true; } // Add to connection manager @@ -139,14 +169,15 @@ async function handleAuth( })); logger.info(`Desktop authenticated: ${sessionId}`); - } catch (error) { + return false; + } catch (error: any) { logger.error('Desktop auth error:', error); socket.send(JSON.stringify({ type: 'auth_response', success: false, - error: 'Authentication failed' + error: error?.message || 'Authentication failed' })); - socket.close(); + return true; } } diff --git a/macropad-relay/src/handlers/webClientHandler.ts b/macropad-relay/src/handlers/webClientHandler.ts index e078d32..aa1c386 100644 --- a/macropad-relay/src/handlers/webClientHandler.ts +++ b/macropad-relay/src/handlers/webClientHandler.ts @@ -4,6 +4,11 @@ import WebSocket from 'ws'; import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager'; import { SessionManager } from '../services/SessionManager'; import { logger } from '../utils/logger'; +import { config } from '../config'; +import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle'; + +// Close code 1008 = policy violation (used for auth abuse). +const WS_POLICY_VIOLATION = 1008; interface AuthMessage { type: 'auth'; @@ -36,6 +41,9 @@ export function handleWebClientConnection( // Add client (not authenticated yet) const client = connectionManager.addWebClient(sessionId, socket, false); + // Track failed auth attempts on this individual socket. + let socketAuthFailures = 0; + // Check if desktop is connected const desktop = connectionManager.getDesktopBySessionId(sessionId); socket.send(JSON.stringify({ @@ -53,9 +61,19 @@ export function handleWebClientConnection( const message: WebClientMessage = JSON.parse(data.toString()); switch (message.type) { - case 'auth': - await handleAuth(socket, client, message, sessionId, sessionManager); + case 'auth': { + const failed = await handleAuth(socket, client, message, sessionId, sessionManager); + if (failed) { + socketAuthFailures++; + if (socketAuthFailures >= config.authMaxSocketFailures) { + logger.warn(`Web client exceeded auth attempts for session ${sessionId}, closing socket`); + socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts'); + } + } else { + socketAuthFailures = 0; + } break; + } case 'ping': socket.send(JSON.stringify({ type: 'pong' })); @@ -82,28 +100,45 @@ export function handleWebClientConnection( }); } +// Returns true if the auth attempt failed (so the caller can count it). async function handleAuth( socket: WebSocket, client: WebClientConnection, message: AuthMessage, sessionId: string, sessionManager: SessionManager -): Promise { +): Promise { + // Reject early if this session id is currently locked out. + const lockRemaining = getLockoutRemaining(sessionId); + if (lockRemaining > 0) { + socket.send(JSON.stringify({ + type: 'auth_response', + success: false, + error: 'Too many failed attempts, try again later', + retryAfterMs: lockRemaining + })); + return true; + } + const valid = await sessionManager.validatePassword(sessionId, message.password); if (valid) { + recordSuccess(sessionId); client.authenticated = true; socket.send(JSON.stringify({ type: 'auth_response', success: true })); logger.debug(`Web client authenticated for session: ${sessionId}`); + return false; } else { + recordFailure(sessionId); socket.send(JSON.stringify({ type: 'auth_response', success: false, error: 'Invalid password' })); + return true; } } diff --git a/macropad-relay/src/server.ts b/macropad-relay/src/server.ts index b0f94d1..a8917b5 100644 --- a/macropad-relay/src/server.ts +++ b/macropad-relay/src/server.ts @@ -3,7 +3,7 @@ import express from 'express'; import fs from 'fs'; import http from 'http'; -import WebSocket, { WebSocketServer } from 'ws'; +import { WebSocketServer } from 'ws'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; @@ -17,19 +17,101 @@ import { handleDesktopConnection } from './handlers/desktopHandler'; import { handleWebClientConnection } from './handlers/webClientHandler'; import { createApiProxy } from './handlers/apiProxy'; +// Resolve the client IP for an upgrade request, honouring the proxy's +// X-Forwarded-For first hop when we trust the proxy. +function getClientIp(request: http.IncomingMessage): string { + if (config.trustProxy) { + const xff = request.headers['x-forwarded-for']; + if (typeof xff === 'string' && xff.length > 0) { + return xff.split(',')[0].trim(); + } + if (Array.isArray(xff) && xff.length > 0) { + return xff[0].split(',')[0].trim(); + } + } + return request.socket.remoteAddress || 'unknown'; +} + +// Simple in-memory sliding-window rate limiter for WS upgrades, keyed by IP. +function createUpgradeRateLimiter() { + const hits = new Map(); + + const cleanup = setInterval(() => { + const cutoff = Date.now() - config.wsUpgradeWindowMs; + for (const [ip, times] of hits) { + const recent = times.filter((t) => t > cutoff); + if (recent.length === 0) { + hits.delete(ip); + } else { + hits.set(ip, recent); + } + } + }, config.wsUpgradeWindowMs); + if (typeof cleanup.unref === 'function') cleanup.unref(); + + return function allow(ip: string): boolean { + const now = Date.now(); + const cutoff = now - config.wsUpgradeWindowMs; + const times = (hits.get(ip) || []).filter((t) => t > cutoff); + times.push(now); + hits.set(ip, times); + return times.length <= config.wsUpgradeMax; + }; +} + +// Validate the Origin header for a WS upgrade against the allowlist. +// Native (desktop) clients send no Origin and are permitted. +function isOriginAllowed(origin: string | undefined, host: string | undefined): boolean { + if (!origin) return true; // non-browser clients (e.g. desktop app) + + if (config.allowedOrigins.length > 0) { + return config.allowedOrigins.includes(origin); + } + + // Default: allow only same-host origins. + try { + return new URL(origin).host === host; + } catch { + return false; + } +} + export function createServer() { const app = express(); const server = http.createServer(app); + // Trust the reverse proxy so client IPs (rate limiting) are accurate. + app.set('trust proxy', config.trustProxy); + // Initialize managers const sessionManager = new SessionManager(); const connectionManager = new ConnectionManager(); // Middleware app.use(helmet({ - contentSecurityPolicy: false // Allow inline scripts for login page + // The bundled login/app pages rely on inline diff --git a/web/js/app.js b/web/js/app.js index 6426e1b..7857565 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,4 +1,14 @@ -// MacroPad PWA Application (Execute-only) +// MacroPad PWA Application + +// Supported command types for the macro builder. Kept in sync with the +// server-side Command model: {type, value?, keys?, ms?, command?}. +const COMMAND_TYPES = [ + { value: 'text', label: 'Text', placeholder: 'Text to type', numeric: false }, + { value: 'key', label: 'Key', placeholder: 'Key name, e.g. enter', numeric: false }, + { value: 'hotkey', label: 'Hotkey', placeholder: 'Keys, e.g. ctrl, c', numeric: false }, + { value: 'wait', label: 'Wait', placeholder: 'Milliseconds', numeric: true }, + { value: 'app', label: 'App', placeholder: 'App or command to launch', numeric: false } +]; class MacroPadApp { constructor() { @@ -16,6 +26,17 @@ class MacroPadApp { this.desktopConnected = true; this.wsAuthenticated = false; + // WebSocket reconnect state (exponential backoff) + this.shouldReconnect = true; + this.reconnectDelay = 1000; + this.reconnectTimer = null; + + // Guards against out-of-order macro fetches + this._macroReqId = 0; + + // Macro editor state (null = create mode) + this.editingMacroId = null; + if (this.relayMode) { this.initRelayMode(); } else { @@ -86,10 +107,10 @@ class MacroPadApp { } async init() { + this.setupEventListeners(); await this.loadTabs(); await this.loadMacros(); this.setupWebSocket(); - this.setupEventListeners(); this.setupWakeLock(); this.checkInstallPrompt(); } @@ -117,6 +138,9 @@ class MacroPadApp { } async loadMacros() { + // Only the latest request is allowed to render, so overlapping + // tab-switch fetches can't apply out of order. + const reqId = ++this._macroReqId; try { const path = this.currentTab === 'All' ? '/api/macros' @@ -124,6 +148,7 @@ class MacroPadApp { const response = await fetch(this.getApiUrl(path), { headers: this.getApiHeaders() }); + if (reqId !== this._macroReqId) return; // superseded if (!response.ok) { if (response.status === 401) { this.handleAuthError(); @@ -136,19 +161,21 @@ class MacroPadApp { throw new Error('Failed to load macros'); } const data = await response.json(); + if (reqId !== this._macroReqId) return; // superseded this.macros = data.macros || {}; this.renderMacros(); } catch (error) { + if (reqId !== this._macroReqId) return; console.error('Error loading macros:', error); this.showToast('Error loading macros', 'error'); } } async executeMacro(macroId) { - try { - const card = document.querySelector(`[data-macro-id="${macroId}"]`); - if (card) card.classList.add('executing'); + const card = this.findCard(macroId); + if (card) card.classList.add('executing'); + try { const response = await fetch(this.getApiUrl('/api/execute'), { method: 'POST', headers: this.getApiHeaders(), @@ -156,18 +183,23 @@ class MacroPadApp { }); if (!response.ok) { + if (response.status === 401) { + this.handleAuthError(); + return; + } if (response.status === 503) { this.handleDesktopDisconnected(); } throw new Error('Execution failed'); } - - setTimeout(() => { - if (card) card.classList.remove('executing'); - }, 300); } catch (error) { console.error('Error executing macro:', error); this.showToast('Error executing macro', 'error'); + } finally { + // Always clear the animation state, even on failure. + setTimeout(() => { + if (card) card.classList.remove('executing'); + }, 300); } } @@ -187,7 +219,37 @@ class MacroPadApp { } // WebSocket + teardownSocket() { + if (this.ws) { + // Detach handlers so the old socket can't fire reconnect logic. + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onmessage = null; + this.ws.onerror = null; + try { + this.ws.close(); + } catch (e) { + /* ignore */ + } + this.ws = null; + } + } + + scheduleReconnect() { + if (!this.shouldReconnect) return; + clearTimeout(this.reconnectTimer); + const delay = this.reconnectDelay; + this.reconnectTimer = setTimeout(() => this.setupWebSocket(), delay); + // Exponential backoff, capped at 30s. + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); + } + setupWebSocket() { + if (!this.shouldReconnect) return; + + // Clean up any prior socket before opening a new one. + this.teardownSocket(); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl; @@ -205,6 +267,8 @@ class MacroPadApp { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { + // Successful connection resets the backoff. + this.reconnectDelay = 1000; if (!this.relayMode) { this.updateConnectionStatus(true); } @@ -214,19 +278,27 @@ class MacroPadApp { this.ws.onclose = () => { this.wsAuthenticated = false; this.updateConnectionStatus(false); - setTimeout(() => this.setupWebSocket(), 3000); + this.scheduleReconnect(); }; this.ws.onmessage = (event) => { - const data = JSON.parse(event.data); + let data; + try { + data = JSON.parse(event.data); + } catch (e) { + // Ignore malformed frames. + return; + } this.handleWebSocketMessage(data); }; this.ws.onerror = () => { + // Let onclose own reconnection; just reflect status here. this.updateConnectionStatus(false); }; } catch (error) { console.error('WebSocket error:', error); + this.scheduleReconnect(); } } @@ -235,7 +307,7 @@ class MacroPadApp { // Relay-specific messages case 'auth_required': // Send authentication - if (this.password) { + if (this.password && this.ws) { this.ws.send(JSON.stringify({ type: 'auth', password: this.password @@ -272,13 +344,14 @@ class MacroPadApp { this.loadMacros(); break; - case 'executed': - const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`); + case 'executed': { + const card = this.findCard(data.macro_id); if (card) { card.classList.add('executing'); setTimeout(() => card.classList.remove('executing'), 300); } break; + } case 'pong': // Keep-alive response @@ -303,73 +376,461 @@ class MacroPadApp { } } - // Rendering + // Deterministic hue from a macro name so the CSS can color placeholders. + hashHue(name) { + const s = String(name || ''); + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = (hash * 31 + s.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 360; + } + + findCard(macroId) { + const cards = document.querySelectorAll('.macro-card'); + for (const card of cards) { + if (card.dataset.macroId === macroId) return card; + } + return null; + } + + macroImageUrl(macro) { + if (!macro.image_path) return null; + const basePath = this.getApiUrl(`/api/image/${macro.image_path}`); + // Credentials go as query params since can't set headers. + if (this.relayMode && this.password) { + return `${basePath}?password=${encodeURIComponent(this.password)}`; + } + if (!this.relayMode && this.localToken) { + return `${basePath}?token=${encodeURIComponent(this.localToken)}`; + } + return basePath; + } + + // Rendering (safe DOM construction only - no user value reaches innerHTML) renderTabs() { const container = document.getElementById('tabs-container'); if (!container) return; - container.innerHTML = this.tabs.map(tab => ` - - `).join(''); + container.textContent = ''; + this.tabs.forEach((tab) => { + const active = tab === this.currentTab; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = active ? 'tab active' : 'tab'; + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + btn.dataset.tab = tab; + btn.textContent = tab; + container.appendChild(btn); + }); } renderMacros() { const container = document.getElementById('macro-grid'); if (!container) return; - const macroEntries = Object.entries(this.macros); + container.textContent = ''; + const macroEntries = Object.entries(this.macros); if (macroEntries.length === 0) { - container.innerHTML = ` -
-

No macros found

-

Create macros in the desktop app

-
- `; + container.appendChild(this.buildEmptyState()); return; } - container.innerHTML = macroEntries.map(([id, macro]) => { - let imageSrc = null; - if (macro.image_path) { - const basePath = this.getApiUrl(`/api/image/${macro.image_path}`); - // Add credential as query param since img tags can't use headers - if (this.relayMode && this.password) { - imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`; - } else if (!this.relayMode && this.localToken) { - imageSrc = `${basePath}?token=${encodeURIComponent(this.localToken)}`; - } else { - imageSrc = basePath; - } - } - const firstChar = macro.name.charAt(0).toUpperCase(); + const fragment = document.createDocumentFragment(); + macroEntries.forEach(([id, macro]) => { + fragment.appendChild(this.buildMacroCard(id, macro)); + }); + container.appendChild(fragment); + } - return ` -
- ${imageSrc - ? `${macro.name}` - : '' - } -
- ${firstChar} -
- ${macro.name} -
- `; - }).join(''); + buildMacroCard(id, macro) { + const name = macro.name || ''; + + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'macro-card'; + card.dataset.macroId = id; + card.setAttribute('aria-label', `Run ${name}`); + card.style.setProperty('--macro-color', `hsl(${this.hashHue(name)} 55% 45%)`); + + const placeholder = document.createElement('span'); + placeholder.className = 'macro-image-placeholder'; + placeholder.textContent = (name.charAt(0) || '?').toUpperCase(); + + const imageSrc = this.macroImageUrl(macro); + if (imageSrc) { + const img = document.createElement('img'); + img.className = 'macro-image'; + img.alt = ''; + img.src = imageSrc; + placeholder.style.display = 'none'; + // Fall back to the placeholder if the image fails to load. + img.addEventListener('error', () => { + img.style.display = 'none'; + placeholder.style.display = ''; + }); + card.appendChild(img); + } + card.appendChild(placeholder); + + const nameEl = document.createElement('span'); + nameEl.className = 'macro-name'; + nameEl.textContent = name; + card.appendChild(nameEl); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'macro-edit-btn'; + editBtn.setAttribute('aria-label', `Edit ${name}`); + editBtn.textContent = '✎'; + editBtn.addEventListener('click', (e) => { + // Don't trigger execute when editing. + e.stopPropagation(); + this.openMacroModal(id); + }); + card.appendChild(editBtn); + + card.addEventListener('click', () => this.executeMacro(id)); + return card; + } + + buildEmptyState() { + const wrap = document.createElement('div'); + wrap.className = 'empty-state'; + + const p1 = document.createElement('p'); + p1.textContent = 'No macros yet'; + + const p2 = document.createElement('p'); + p2.className = 'hint'; + p2.textContent = 'Build one right here in your browser.'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-primary'; + btn.textContent = 'Create your first macro'; + btn.addEventListener('click', () => this.openMacroModal()); + + wrap.append(p1, p2, btn); + return wrap; + } + + // ---- Macro editor (create / edit / delete) ---- + + populateCategoryOptions() { + const list = document.getElementById('macro-category-list'); + if (!list) return; + list.textContent = ''; + this.tabs + .filter((t) => t && t !== 'All') + .forEach((t) => { + const opt = document.createElement('option'); + opt.value = t; + list.appendChild(opt); + }); + } + + commandToInputValue(cmd) { + switch (cmd.type) { + case 'hotkey': + return Array.isArray(cmd.keys) ? cmd.keys.join(', ') : (cmd.keys || ''); + case 'wait': + return cmd.ms != null ? String(cmd.ms) : ''; + case 'app': + return cmd.command || ''; + case 'text': + case 'key': + default: + return cmd.value || ''; + } + } + + applyCommandRowType(select, input) { + const meta = COMMAND_TYPES.find((t) => t.value === select.value) || COMMAND_TYPES[0]; + input.type = meta.numeric ? 'number' : 'text'; + input.placeholder = meta.placeholder; + if (meta.numeric) { + input.min = '0'; + } else { + input.removeAttribute('min'); + } + } + + addCommandRow(type = 'text', cmd = null) { + const list = document.getElementById('command-list'); + if (!list) return; + + const row = document.createElement('div'); + row.className = 'command-item'; + + const select = document.createElement('select'); + select.className = 'command-type-select'; + COMMAND_TYPES.forEach((t) => { + const opt = document.createElement('option'); + opt.value = t.value; + opt.textContent = t.label; + select.appendChild(opt); + }); + select.value = COMMAND_TYPES.some((t) => t.value === type) ? type : 'text'; + + const input = document.createElement('input'); + input.className = 'command-value'; + input.autocomplete = 'off'; + + const actions = document.createElement('div'); + actions.className = 'command-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.textContent = '↑'; + upBtn.setAttribute('aria-label', 'Move step up'); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.textContent = '↓'; + downBtn.setAttribute('aria-label', 'Move step down'); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'delete'; + delBtn.textContent = '✕'; + delBtn.setAttribute('aria-label', 'Remove step'); + + this.applyCommandRowType(select, input); + if (cmd) { + input.value = this.commandToInputValue(cmd); + } + + select.addEventListener('change', () => this.applyCommandRowType(select, input)); + upBtn.addEventListener('click', () => { + const prev = row.previousElementSibling; + if (prev) list.insertBefore(row, prev); + }); + downBtn.addEventListener('click', () => { + const next = row.nextElementSibling; + if (next) list.insertBefore(next, row); + }); + delBtn.addEventListener('click', () => row.remove()); + + actions.append(upBtn, downBtn, delBtn); + row.append(select, input, actions); + list.appendChild(row); + } + + renderCommandRows(commands) { + const list = document.getElementById('command-list'); + if (!list) return; + list.textContent = ''; + commands.forEach((cmd) => this.addCommandRow(cmd.type || 'text', cmd)); + } + + collectCommands() { + const list = document.getElementById('command-list'); + if (!list) return []; + const commands = []; + list.querySelectorAll('.command-item').forEach((row) => { + const type = row.querySelector('.command-type-select').value; + const raw = row.querySelector('.command-value').value.trim(); + switch (type) { + case 'hotkey': { + // Store keys as a list (split on commas/whitespace). + const keys = raw.split(/[\s,]+/).filter(Boolean); + commands.push({ type: 'hotkey', keys }); + break; + } + case 'wait': { + const ms = parseInt(raw, 10); + commands.push({ type: 'wait', ms: Number.isFinite(ms) ? ms : 0 }); + break; + } + case 'app': + commands.push({ type: 'app', command: raw }); + break; + case 'key': + commands.push({ type: 'key', value: raw }); + break; + case 'text': + default: + commands.push({ type: 'text', value: raw }); + break; + } + }); + return commands; + } + + async openMacroModal(macroId = null) { + this.editingMacroId = macroId || null; + + const modal = document.getElementById('macro-modal'); + const title = document.getElementById('macro-modal-title'); + const nameInput = document.getElementById('macro-name-input'); + const categoryInput = document.getElementById('macro-category-input'); + const deleteBtn = document.getElementById('delete-macro-btn'); + if (!modal) return; + + this.populateCategoryOptions(); + nameInput.value = ''; + categoryInput.value = ''; + this.renderCommandRows([]); + + if (macroId) { + title.textContent = 'Edit Macro'; + if (deleteBtn) deleteBtn.hidden = false; + try { + const res = await fetch( + this.getApiUrl(`/api/macro/${encodeURIComponent(macroId)}`), + { headers: this.getApiHeaders() } + ); + if (!res.ok) { + if (res.status === 401) { this.handleAuthError(); return; } + throw new Error('Failed to load macro'); + } + const data = await res.json(); + const macro = data.macro || {}; + nameInput.value = macro.name || ''; + categoryInput.value = macro.category || ''; + this.renderCommandRows(Array.isArray(macro.commands) ? macro.commands : []); + } catch (e) { + console.error('Error loading macro:', e); + this.showToast('Could not load macro', 'error'); + } + } else { + title.textContent = 'New Macro'; + if (deleteBtn) deleteBtn.hidden = true; + categoryInput.value = (this.currentTab && this.currentTab !== 'All') ? this.currentTab : ''; + this.addCommandRow('text'); + } + + modal.hidden = false; + nameInput.focus(); + } + + closeMacroModal() { + const modal = document.getElementById('macro-modal'); + if (modal) modal.hidden = true; + this.editingMacroId = null; + } + + async saveMacro() { + const nameInput = document.getElementById('macro-name-input'); + const categoryInput = document.getElementById('macro-category-input'); + const saveBtn = document.getElementById('save-macro-btn'); + + const name = nameInput.value.trim(); + const category = categoryInput.value.trim(); + const commands = this.collectCommands(); + + if (!name) { + this.showToast('Please enter a name', 'error'); + nameInput.focus(); + return; + } + if (commands.length === 0) { + this.showToast('Add at least one step', 'error'); + return; + } + + const editing = this.editingMacroId; + const url = editing + ? this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`) + : this.getApiUrl('/api/macros'); + const method = editing ? 'PUT' : 'POST'; + const body = JSON.stringify({ name, commands, category }); + + if (saveBtn) saveBtn.disabled = true; + try { + const res = await fetch(url, { + method, + headers: this.getApiHeaders(), + body + }); + if (!res.ok) { + if (res.status === 401) { this.handleAuthError(); return; } + throw new Error('Save failed'); + } + this.showToast(editing ? 'Macro updated' : 'Macro created', 'success'); + this.closeMacroModal(); + // WS broadcast will also refresh, but reload immediately for snappiness. + await this.loadTabs(); + await this.loadMacros(); + } catch (e) { + console.error('Error saving macro:', e); + this.showToast('Error saving macro', 'error'); + } finally { + if (saveBtn) saveBtn.disabled = false; + } + } + + async deleteMacro() { + const editing = this.editingMacroId; + if (!editing) return; + + const nameInput = document.getElementById('macro-name-input'); + const name = (nameInput && nameInput.value.trim()) || 'this macro'; + if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) return; + + try { + const res = await fetch( + this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`), + { method: 'DELETE', headers: this.getApiHeaders() } + ); + if (!res.ok) { + if (res.status === 401) { this.handleAuthError(); return; } + throw new Error('Delete failed'); + } + this.showToast('Macro deleted', 'success'); + this.closeMacroModal(); + await this.loadTabs(); + await this.loadMacros(); + } catch (e) { + console.error('Error deleting macro:', e); + this.showToast('Error deleting macro', 'error'); + } } // Event Listeners setupEventListeners() { // Tab clicks document.getElementById('tabs-container')?.addEventListener('click', (e) => { - if (e.target.classList.contains('tab')) { - this.currentTab = e.target.dataset.tab; + const tab = e.target.closest('.tab'); + if (tab) { + this.currentTab = tab.dataset.tab; this.renderTabs(); this.loadMacros(); } }); + + // Header controls + document.getElementById('refresh-btn')?.addEventListener('click', () => this.refresh()); + document.getElementById('fullscreen-btn')?.addEventListener('click', () => this.toggleFullscreen()); + + // Macro editor controls + document.getElementById('new-macro-btn')?.addEventListener('click', () => this.openMacroModal()); + document.getElementById('add-command-btn')?.addEventListener('click', () => this.addCommandRow('text')); + document.getElementById('save-macro-btn')?.addEventListener('click', () => this.saveMacro()); + document.getElementById('cancel-macro-btn')?.addEventListener('click', () => this.closeMacroModal()); + document.getElementById('delete-macro-btn')?.addEventListener('click', () => this.deleteMacro()); + document.getElementById('macro-modal-close')?.addEventListener('click', () => this.closeMacroModal()); + + const modal = document.getElementById('macro-modal'); + modal?.addEventListener('click', (e) => { + // Click on the backdrop (not the content) closes. + if (e.target === modal) this.closeMacroModal(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal && !modal.hidden) this.closeMacroModal(); + }); + + // Stop reconnecting once the page is going away. + const stop = () => { + this.shouldReconnect = false; + clearTimeout(this.reconnectTimer); + this.teardownSocket(); + }; + window.addEventListener('pagehide', stop); + window.addEventListener('beforeunload', stop); } // Toast notifications @@ -401,13 +862,26 @@ class MacroPadApp { showInstallBanner(deferredPrompt) { const banner = document.createElement('div'); banner.className = 'install-banner'; - banner.innerHTML = ` - Install MacroPad for quick access -
- - -
- `; + + const label = document.createElement('span'); + label.textContent = 'Install MacroPad for quick access'; + + const actions = document.createElement('div'); + + const installBtn = document.createElement('button'); + installBtn.type = 'button'; + installBtn.textContent = 'Install'; + installBtn.addEventListener('click', () => this.installPWA()); + + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'dismiss'; + dismissBtn.textContent = '✕'; + dismissBtn.setAttribute('aria-label', 'Dismiss install banner'); + dismissBtn.addEventListener('click', () => banner.remove()); + + actions.append(installBtn, dismissBtn); + banner.append(label, actions); document.body.insertBefore(banner, document.body.firstChild); this.deferredPrompt = deferredPrompt; } @@ -434,7 +908,7 @@ class MacroPadApp { // Fullscreen toggleFullscreen() { if (!document.fullscreenElement) { - document.documentElement.requestFullscreen().catch(err => { + document.documentElement.requestFullscreen().catch((err) => { console.log('Fullscreen error:', err); }); } else { diff --git a/web/service-worker.js b/web/service-worker.js index d5b141c..d7e5c7c 100644 --- a/web/service-worker.js +++ b/web/service-worker.js @@ -1,5 +1,5 @@ // MacroPad PWA Service Worker -const CACHE_NAME = 'macropad-v3'; +const CACHE_NAME = 'macropad-v4'; const ASSETS_TO_CACHE = [ '/', '/static/css/styles.css', @@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [ '/manifest.json' ]; -// Install event - cache assets +// Install event - pre-cache the app shell self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME) @@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => { ); }); -// Fetch event - serve from cache, fallback to network -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); +// Network-first for navigations + app code so deployed fixes reach clients. +async function networkFirst(request) { + const cache = await caches.open(CACHE_NAME); + try { + const networkResponse = await fetch(request); + if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (err) { + const cached = await cache.match(request); + if (cached) { + return cached; + } + // Offline fallback for navigations: serve the cached shell. + if (request.mode === 'navigate') { + const shell = await cache.match('/'); + if (shell) { + return shell; + } + } + return new Response('Offline', { + status: 503, + statusText: 'Offline', + headers: { 'Content-Type': 'text/plain' } + }); + } +} - // Always fetch API requests from network - if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) { - event.respondWith(fetch(event.request)); +self.addEventListener('fetch', (event) => { + const request = event.request; + + // Only handle GET; let the browser deal with POST/PUT/DELETE directly. + if (request.method !== 'GET') { return; } - // For other requests, try cache first, then network - event.respondWith( - caches.match(event.request) - .then((response) => { - if (response) { - return response; - } - return fetch(event.request).then((networkResponse) => { - // Cache successful responses - if (networkResponse && networkResponse.status === 200) { - const responseClone = networkResponse.clone(); - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseClone); - }); - } - return networkResponse; - }); - }) - .catch(() => { - // Return offline fallback for navigation requests - if (event.request.mode === 'navigate') { - return caches.match('/'); - } - }) - ); + const url = new URL(request.url); + + // API requests always go to the network; never serve stale data. + // Return a synthetic offline JSON response instead of an unhandled undefined. + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(request).catch(() => new Response( + JSON.stringify({ error: 'offline', detail: 'Network unavailable' }), + { status: 503, headers: { 'Content-Type': 'application/json' } } + )) + ); + return; + } + + // Navigations and static app assets: network-first with cache fallback. + event.respondWith(networkFirst(request)); }); From beba42586830f3dede7957b523952c012dbbbcb6 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:26:22 -0700 Subject: [PATCH 04/11] security+robustness(py/gui): thread-safety, upload hardening, keyring macro_manager: - All macro reads/writes guarded by a lock; macros.json written atomically (temp + fsync + os.replace). Commands are copied under the lock and executed outside it so wait/keyboard steps don't block other threads. - last_used disk writes debounced (>=5s) instead of a full rewrite per press. - Validate key/hotkey names against pyautogui.KEYBOARD_KEYS and clamp wait ms; invalid input is skipped/logged, not blindly executed. web_server: - WebSocket receive loop always disconnects (fixes dead-socket leak); broadcast prunes failed sockets. - POST /api/execute offloaded to a thread executor so a long macro no longer blocks the event loop. - Image upload hardened: 5MB cap (413), real-image validation via Pillow, server-generated name under macro_images/, returns a relative path only, cleans up on failure. gui: - Execute macros off the Qt main thread (signal back to status bar); debounce resizeEvent and only rebuild when the column count changes. - Relay password stored in the OS keyring when available (plaintext JSON blanked; graceful fallback if no backend); delete dialogs name the macro; honor the minimize-to-tray setting; surface save failures. - Add media/system keys (volume, play/pause, track nav) to the macro editor. - THEME gains shared danger/accent-hover colors; drop dead imports. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.py | 5 +- gui/macro_editor.py | 45 +++++--- gui/main_window.py | 72 ++++++++++--- gui/settings_dialog.py | 12 ++- gui/settings_manager.py | 49 ++++++++- macro_manager.py | 233 ++++++++++++++++++++++++++++------------ web_server.py | 103 +++++++++++++++--- 7 files changed, 402 insertions(+), 117 deletions(-) diff --git a/config.py b/config.py index b73ab9e..49364f1 100644 --- a/config.py +++ b/config.py @@ -13,7 +13,10 @@ THEME = { 'button_bg': "#505050", 'button_fg': "#ffffff", 'tab_bg': "#404040", - 'tab_selected': "#007acc" + 'tab_selected': "#007acc", + 'accent_hover': "#0096ff", + 'danger_color': "#dc3545", + 'danger_hover': "#c82333" } # File extensions for images diff --git a/gui/macro_editor.py b/gui/macro_editor.py index 39c0ba7..422a34b 100644 --- a/gui/macro_editor.py +++ b/gui/macro_editor.py @@ -1,19 +1,30 @@ # Macro editor dialog with command builder (PySide6) import os -from typing import Optional, List, Dict +from typing import Optional, List from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem, - QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget, - QGroupBox, QScrollArea, QCompleter + QComboBox, QMessageBox, QFileDialog, QWidget, + QGroupBox, QScrollArea ) from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QPixmap, QIcon +from PySide6.QtGui import QPixmap from config import THEME, IMAGE_EXTENSIONS +# Selectable single keys offered in the key picker. Includes common media/system +# keys pyautogui supports, a near-zero-cost win for a media/stream deck. +KEY_CHOICES = [ + "Enter", "Tab", "Escape", "Space", "Backspace", "Delete", + "Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown", + "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", + # Media / system keys + "VolumeUp", "VolumeDown", "VolumeMute", + "PlayPause", "NextTrack", "PrevTrack", "Stop", +] + class CommandItem(QWidget): """Widget representing a single command in the list.""" @@ -86,7 +97,7 @@ class CommandItem(QWidget): del_btn = QPushButton("X") del_btn.setStyleSheet(f""" QPushButton {{ - background-color: #dc3545; + background-color: {THEME['danger_color']}; color: white; border: none; padding: 6px 8px; @@ -94,7 +105,7 @@ class CommandItem(QWidget): min-height: 20px; }} QPushButton:hover {{ - background-color: #c82333; + background-color: {THEME['danger_hover']}; }} """) del_btn.setFixedSize(30, 28) @@ -212,9 +223,7 @@ class CommandBuilder(QWidget): elif cmd_type == "key": from PySide6.QtWidgets import QInputDialog - keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete", - "Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown", - "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"] + keys = list(KEY_CHOICES) key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True) if not ok or not key: return @@ -295,9 +304,7 @@ class CommandBuilder(QWidget): cmd["value"] = text elif cmd_type == "key": - keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete", - "Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown", - "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"] + keys = list(KEY_CHOICES) keys_lower = [k.lower() for k in keys] current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0 key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True) @@ -511,7 +518,7 @@ class MacroEditorDialog(QDialog): delete_btn = QPushButton("Delete") delete_btn.setStyleSheet(f""" QPushButton {{ - background-color: #dc3545; + background-color: {THEME['danger_color']}; color: white; border: none; padding: 10px 20px; @@ -519,7 +526,7 @@ class MacroEditorDialog(QDialog): font-weight: bold; }} QPushButton:hover {{ - background-color: #c82333; + background-color: {THEME['danger_hover']}; }} """) delete_btn.clicked.connect(self.delete_macro) @@ -554,7 +561,7 @@ class MacroEditorDialog(QDialog): font-weight: bold; }} QPushButton:hover {{ - background-color: #0096ff; + background-color: {THEME['accent_hover']}; }} """) save_btn.clicked.connect(self.save_macro) @@ -634,9 +641,15 @@ class MacroEditorDialog(QDialog): def delete_macro(self): """Delete the current macro.""" + macro_name = self.name_input.text().strip() + prompt = ( + f"Are you sure you want to delete \"{macro_name}\"?" + if macro_name else + "Are you sure you want to delete this macro?" + ) reply = QMessageBox.question( self, "Delete Macro", - "Are you sure you want to delete this macro?", + prompt, QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: diff --git a/gui/main_window.py b/gui/main_window.py index 09f3718..68eb75e 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -21,11 +21,11 @@ def get_resource_path(relative_path): from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTabWidget, QGridLayout, - QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar, + QScrollArea, QMenu, QStatusBar, QMessageBox, QApplication, QSystemTrayIcon, QStyle ) -from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent -from PySide6.QtGui import QIcon, QPixmap, QAction, QFont +from PySide6.QtCore import Qt, Signal, QTimer, QEvent +from PySide6.QtGui import QIcon, QPixmap, QAction from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE from macro_manager import MacroManager @@ -127,6 +127,8 @@ class MainWindow(QMainWindow): relay_session_received = Signal(str) relay_connected_signal = Signal() relay_disconnected_signal = Signal() + # Report macro execution result back to the GUI thread (success flag). + macro_execution_finished = Signal(bool) def __init__(self, app_dir: str): super().__init__() @@ -174,6 +176,14 @@ class MainWindow(QMainWindow): self.relay_session_received.connect(self._handle_relay_session) self.relay_connected_signal.connect(lambda: self._update_relay_status(True)) self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False)) + self.macro_execution_finished.connect(self._on_macro_execution_finished) + + # Debounce timer for resize-driven grid rebuilds. + self._resize_timer = QTimer(self) + self._resize_timer.setSingleShot(True) + self._resize_timer.setInterval(150) + self._resize_timer.timeout.connect(self._apply_resize) + self._last_col_count = None # Load initial data self.refresh_tabs() @@ -209,7 +219,7 @@ class MainWindow(QMainWindow): font-weight: bold; }} QPushButton:hover {{ - background-color: #0096ff; + background-color: {THEME['accent_hover']}; }} """) add_btn.clicked.connect(self.add_macro) @@ -555,7 +565,8 @@ class MainWindow(QMainWindow): filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name) # Add macro buttons - cols = max(1, (self.width() - 40) // 130) + cols = self._compute_columns() + self._last_col_count = cols for i, (macro_id, macro) in enumerate(filtered): btn = MacroButton(macro_id, macro, self.app_dir) btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid)) @@ -568,8 +579,25 @@ class MainWindow(QMainWindow): self.refresh_macros() def execute_macro(self, macro_id: str): - """Execute a macro.""" - success = self.macro_manager.execute_macro(macro_id) + """Execute a macro off the GUI thread so the UI doesn't freeze. + + pyautogui presses and wait steps are blocking; running them on the Qt + main thread would freeze the window. Run in a worker thread and report + the result back via a signal (thread-safe). + """ + self.status_bar.showMessage("Executing macro...", 2000) + + def run(): + try: + success = self.macro_manager.execute_macro(macro_id) + except Exception: + success = False + self.macro_execution_finished.emit(success) + + threading.Thread(target=run, daemon=True).start() + + def _on_macro_execution_finished(self, success: bool): + """Report macro execution result on the GUI thread.""" if success: self.status_bar.showMessage("Macro executed", 2000) else: @@ -593,9 +621,16 @@ class MainWindow(QMainWindow): def delete_macro(self, macro_id: str): """Delete a macro with confirmation.""" + macro = self.macro_manager.get_macro(macro_id) + macro_name = macro.get("name", "") if macro else "" + prompt = ( + f"Are you sure you want to delete \"{macro_name}\"?" + if macro_name else + "Are you sure you want to delete this macro?" + ) reply = QMessageBox.question( self, "Delete Macro", - "Are you sure you want to delete this macro?", + prompt, QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: @@ -738,7 +773,8 @@ class MainWindow(QMainWindow): def _handle_relay_session(self, session_id: str): """Handle relay session on main thread.""" - self.settings_manager.set_relay_session_id(session_id) + if not self.settings_manager.set_relay_session_id(session_id): + self.status_bar.showMessage("Warning: failed to save relay session", 3000) self.update_ip_label() def _update_relay_status(self, connected: bool): @@ -825,15 +861,27 @@ class MainWindow(QMainWindow): event.accept() + def _compute_columns(self) -> int: + """Compute how many macro columns fit at the current width.""" + return max(1, (self.width() - 40) // 130) + + def _apply_resize(self): + """Rebuild the grid only if the column count actually changed.""" + if self._compute_columns() != self._last_col_count: + self.refresh_macros() + def resizeEvent(self, event): - """Handle window resize.""" + """Handle window resize (debounced to coalesce rapid resize events).""" super().resizeEvent(event) - self.refresh_macros() + # Coalesce a burst of resize events; only rebuild after things settle + # and only when the computed column count changed. + self._resize_timer.start() def changeEvent(self, event): """Handle window state changes - minimize to tray.""" if event.type() == QEvent.Type.WindowStateChange: - if self.windowState() & Qt.WindowMinimized: + if (self.windowState() & Qt.WindowMinimized + and self.settings_manager.get_minimize_to_tray()): # Hide instead of minimize (goes to tray) event.ignore() self.hide() diff --git a/gui/settings_dialog.py b/gui/settings_dialog.py index 68d1de2..f29aa9e 100644 --- a/gui/settings_dialog.py +++ b/gui/settings_dialog.py @@ -123,7 +123,7 @@ class SettingsDialog(QDialog): color: white; }} QPushButton:hover {{ - background-color: #0096ff; + background-color: {THEME['accent_hover']}; }} """) save_btn.clicked.connect(self.save_settings) @@ -316,9 +316,15 @@ class SettingsDialog(QDialog): self.settings_manager.set('relay.enabled', new_enabled) self.settings_manager.set('relay.server_url', new_url) - self.settings_manager.set('relay.password', new_password) - self.settings_manager.save() + # Store the password via the keyring-aware setter (also persists the + # rest of the settings). Avoid writing the plaintext password to JSON. + ok = self.settings_manager.set_relay_password(new_password) + if not ok: + QMessageBox.warning( + self, "Save Warning", + "Settings could not be fully saved to disk." + ) if relay_changed: self.relay_settings_changed.emit() diff --git a/gui/settings_manager.py b/gui/settings_manager.py index a0ac3f5..2d24de7 100644 --- a/gui/settings_manager.py +++ b/gui/settings_manager.py @@ -5,6 +5,16 @@ import json import secrets from typing import Any, Optional +# Optional OS keyring for storing the relay password at rest. If the library or +# a working backend is unavailable we fall back to the JSON file (now chmod 0600). +try: + import keyring as _keyring +except Exception: # ImportError or any backend import-time failure + _keyring = None + +KEYRING_SERVICE = "MacroPad" +KEYRING_USER = "relay" + DEFAULT_SETTINGS = { "relay": { @@ -109,13 +119,44 @@ class SettingsManager: return self.get('relay.session_id') def get_relay_password(self) -> str: - """Get the relay password.""" + """Get the relay password, preferring the OS keyring when available.""" + if _keyring is not None: + try: + value = _keyring.get_password(KEYRING_SERVICE, KEYRING_USER) + if value: + return value + except Exception: + # Keyring backend missing/broken: fall back to JSON. + pass return self.get('relay.password', '') - def set_relay_session_id(self, session_id: str): - """Store the relay session ID.""" + def set_relay_password(self, password: str) -> bool: + """Store the relay password, preferring the OS keyring when available. + + When keyring works, the plaintext password is kept out of the JSON file. + Never crashes if the keyring backend is missing; falls back to JSON. + Returns True on successful persistence. + """ + password = password or "" + stored_in_keyring = False + if _keyring is not None: + try: + _keyring.set_password(KEYRING_SERVICE, KEYRING_USER, password) + stored_in_keyring = True + except Exception: + stored_in_keyring = False + + if stored_in_keyring: + # Don't persist plaintext in the JSON when the keyring holds it. + self.set('relay.password', "") + else: + self.set('relay.password', password) + return self.save() + + def set_relay_session_id(self, session_id: str) -> bool: + """Store the relay session ID. Returns True on successful save.""" self.set('relay.session_id', session_id) - self.save() + return self.save() def get_web_auth_token(self) -> str: """Get the web API auth token.""" diff --git a/macro_manager.py b/macro_manager.py index 6ece051..4c94b19 100644 --- a/macro_manager.py +++ b/macro_manager.py @@ -4,6 +4,8 @@ import copy import json import os import shlex +import tempfile +import threading import uuid import pyautogui import subprocess @@ -12,37 +14,61 @@ from PIL import Image from typing import Optional +# --- Payload validation for execution --------------------------------------- +# Allowlist of key names pyautogui knows how to press. Anything not in this set +# is rejected before being handed to pyautogui so arbitrary strings can't be +# passed through. Populated from pyautogui when available. +VALID_KEYS = frozenset(getattr(pyautogui, "KEYBOARD_KEYS", []) or []) + +# Upper bound (milliseconds) for a single wait step so a macro can't hang the +# executor forever. +MAX_WAIT_MS = 60000 + +# Only persist last_used to disk at most this often (seconds) to avoid rewriting +# the whole JSON file on every single execution. +LAST_USED_SAVE_INTERVAL = 5.0 + + class MacroManager: - """Manages macro storage, migration, and execution with command sequences.""" + """Manages macro storage, migration, and execution with command sequences. + + Thread-safe: all reads/writes of ``self.macros`` and all disk writes are + guarded by ``self._lock``. Public methods acquire the lock and delegate to + ``*_locked`` internal helpers so we never re-acquire a non-reentrant lock. + """ def __init__(self, data_file: str, images_dir: str, app_dir: str): self.data_file = data_file self.images_dir = images_dir self.app_dir = app_dir self.macros = {} + self._lock = threading.Lock() + # Timestamp of the last last_used-triggered save (for debouncing). + self._last_used_save_time = 0.0 self.load_macros() def load_macros(self): """Load macros from JSON file and migrate if needed.""" - try: - if os.path.exists(self.data_file): - with open(self.data_file, "r") as file: - self.macros = json.load(file) + with self._lock: + try: + if os.path.exists(self.data_file): + with open(self.data_file, "r") as file: + self.macros = json.load(file) - # Migrate old format macros - migrated = False - for macro_id, macro in list(self.macros.items()): - if macro.get("type") != "sequence": - self.macros[macro_id] = self._migrate_macro(macro) - migrated = True + # Migrate old format macros + migrated = False + for macro_id, macro in list(self.macros.items()): + if macro.get("type") != "sequence": + self.macros[macro_id] = self._migrate_macro(macro) + migrated = True - if migrated: - self.save_macros() - print("Migrated macros to new command sequence format") + if migrated: + self._save_macros_locked() + print("Migrated macros to new command sequence format") - except Exception as e: - print(f"Error loading macros: {e}") - self.macros = {} + except Exception as e: + print(f"Error loading macros: {e}") + self.macros = {} def _migrate_macro(self, old_macro: dict) -> dict: """Convert old macro format to new command sequence format.""" @@ -92,28 +118,56 @@ class MacroManager: } def save_macros(self): - """Save macros to JSON file.""" + """Save macros to JSON file (thread-safe, atomic).""" + with self._lock: + self._save_macros_locked() + + def _save_macros_locked(self): + """Atomically write macros to disk. Caller must hold ``self._lock``. + + Writes to a temp file in the same directory then os.replace() over the + target so a crash mid-write can't leave a truncated/corrupt JSON file. + """ try: - with open(self.data_file, "w") as file: - json.dump(self.macros, file, indent=4) + target_dir = os.path.dirname(self.data_file) or "." + os.makedirs(target_dir, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + prefix=".macros-", suffix=".tmp", dir=target_dir + ) + try: + with os.fdopen(fd, "w") as file: + json.dump(self.macros, file, indent=4) + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, self.data_file) + except Exception: + # Clean up the temp file on any failure + try: + os.remove(tmp_path) + except OSError: + pass + raise except Exception as e: print(f"Error saving macros: {e}") def get_sorted_macros(self, sort_by: str = "name"): """Get macros sorted by specified criteria.""" - macro_list = list(self.macros.items()) + with self._lock: + macro_list = list(self.macros.items()) if sort_by == "name": - macro_list.sort(key=lambda x: x[1]["name"].lower()) + macro_list.sort(key=lambda x: x[1].get("name", "").lower()) elif sort_by == "type": # Sort by first command type in sequence def get_first_type(macro): cmds = macro.get("commands", []) return cmds[0].get("type", "") if cmds else "" - macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower())) + macro_list.sort( + key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower()) + ) elif sort_by == "recent": macro_list.sort( - key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), + key=lambda x: (x[1].get("last_used", 0), x[1].get("name", "").lower()), reverse=True ) @@ -142,9 +196,10 @@ class MacroManager: tabs = ["All"] categories = set() - for macro in self.macros.values(): - if macro.get("category"): - categories.add(macro["category"]) + with self._lock: + for macro in self.macros.values(): + if macro.get("category"): + categories.add(macro["category"]) for category in sorted(categories): if category and category not in tabs: @@ -162,7 +217,7 @@ class MacroManager: """Add a new macro with command sequence.""" macro_id = str(uuid.uuid4()) - # Process image if provided + # Process image if provided (file IO; does not touch self.macros) image_path_reference = self._process_image(image_path) if image_path else "" # Create macro data (deep copy commands to avoid reference issues) @@ -175,8 +230,9 @@ class MacroManager: "last_used": 0 } - self.macros[macro_id] = macro_data - self.save_macros() + with self._lock: + self.macros[macro_id] = macro_data + self._save_macros_locked() return macro_id def update_macro( @@ -188,28 +244,32 @@ class MacroManager: image_path: Optional[str] = None ) -> bool: """Update an existing macro.""" - if macro_id not in self.macros: - return False + with self._lock: + if macro_id not in self.macros: + return False + existing_image = self.macros[macro_id].get("image_path", "") + existing_last_used = self.macros[macro_id].get("last_used", 0) - macro = self.macros[macro_id] - - # Keep old image or update with new one + # Keep old image or update with new one (file IO outside the lock) if image_path is not None: image_path_reference = self._process_image(image_path) if image_path else "" else: - image_path_reference = macro.get("image_path", "") + image_path_reference = existing_image - # Update macro data (deep copy commands to avoid reference issues) - self.macros[macro_id] = { + new_data = { "name": name, "type": "sequence", "commands": copy.deepcopy(commands), "category": category, "image_path": image_path_reference, - "last_used": macro.get("last_used", 0) + "last_used": existing_last_used } - self.save_macros() + with self._lock: + if macro_id not in self.macros: + return False + self.macros[macro_id] = new_data + self._save_macros_locked() return True def _process_image(self, image_path: str) -> str: @@ -234,37 +294,50 @@ class MacroManager: def delete_macro(self, macro_id: str) -> bool: """Delete a macro.""" - if macro_id not in self.macros: - return False + with self._lock: + if macro_id not in self.macros: + return False - macro = self.macros[macro_id] + macro = self.macros[macro_id] + image_path = macro.get("image_path") + del self.macros[macro_id] + self._save_macros_locked() - # Delete associated image file - if macro.get("image_path"): + # Delete associated image file (outside the lock; pure file IO) + if image_path: try: - img_path = os.path.join(self.app_dir, macro["image_path"]) + img_path = os.path.join(self.app_dir, image_path) if os.path.exists(img_path): os.remove(img_path) except Exception as e: print(f"Error removing image file: {e}") - del self.macros[macro_id] - self.save_macros() return True def execute_macro(self, macro_id: str) -> bool: - """Execute a macro by ID.""" - if macro_id not in self.macros: - return False + """Execute a macro by ID. - macro = self.macros[macro_id] + The macro's commands are copied under the lock, then executed *outside* + the lock so long-running wait/keyboard steps don't block other threads + (web server, GUI). last_used is updated in memory and only persisted at + most every LAST_USED_SAVE_INTERVAL seconds to avoid rewriting the whole + file on every press. + """ + with self._lock: + if macro_id not in self.macros: + return False - # Update last_used timestamp - self.macros[macro_id]["last_used"] = time.time() - self.save_macros() + macro = self.macros[macro_id] + commands = copy.deepcopy(macro.get("commands", [])) + + # Update last_used in memory; debounce the disk write. + now = time.time() + macro["last_used"] = now + if now - self._last_used_save_time >= LAST_USED_SAVE_INTERVAL: + self._last_used_save_time = now + self._save_macros_locked() try: - commands = macro.get("commands", []) for cmd in commands: self._execute_command(cmd) return True @@ -272,6 +345,22 @@ class MacroManager: print(f"Error executing macro: {e}") return False + @staticmethod + def _valid_keys(keys) -> list: + """Filter a list of key names to those pyautogui recognises.""" + result = [] + for k in keys: + if not isinstance(k, str): + continue + name = k.strip().lower() + if not name: + continue + if VALID_KEYS and name not in VALID_KEYS: + print(f"Skipping invalid key: {name!r}") + continue + result.append(name) + return result + def _execute_command(self, cmd: dict): """Execute a single command from a sequence.""" cmd_type = cmd.get("type", "") @@ -294,10 +383,11 @@ class MacroManager: pyautogui.typewrite(value, interval=0.02) elif cmd_type == "key": - # Press a single key + # Press a single key (validated against pyautogui's known keys) key = cmd.get("value", "") - if key: - pyautogui.press(key) + valid = self._valid_keys([key]) if key else [] + if valid: + pyautogui.press(valid[0]) elif cmd_type == "hotkey": # Press key combination @@ -306,14 +396,22 @@ class MacroManager: # Ensure keys is a list, not a string if isinstance(keys, str): keys = [k.strip().lower() for k in keys.split(",")] - # Small delay before hotkey for reliability on Windows - time.sleep(0.05) - pyautogui.hotkey(*keys, interval=0.05) + valid = self._valid_keys(keys) + # Only fire if every requested key was valid; a partial hotkey + # is worse than nothing. + if valid and len(valid) == len([k for k in keys if str(k).strip()]): + # Small delay before hotkey for reliability on Windows + time.sleep(0.05) + pyautogui.hotkey(*valid, interval=0.05) elif cmd_type == "wait": - # Delay in milliseconds - ms = cmd.get("ms", 0) + # Delay in milliseconds (bounded, non-negative) + try: + ms = int(cmd.get("ms", 0)) + except (TypeError, ValueError): + ms = 0 if ms > 0: + ms = min(ms, MAX_WAIT_MS) time.sleep(ms / 1000.0) elif cmd_type == "app": @@ -366,8 +464,11 @@ class MacroManager: def get_macro(self, macro_id: str) -> Optional[dict]: """Get a macro by ID.""" - return self.macros.get(macro_id) + with self._lock: + macro = self.macros.get(macro_id) + return copy.deepcopy(macro) if macro is not None else None def get_all_macros(self) -> dict: """Get all macros.""" - return self.macros.copy() + with self._lock: + return copy.deepcopy(self.macros) diff --git a/web_server.py b/web_server.py index 017ca68..5ba71d6 100644 --- a/web_server.py +++ b/web_server.py @@ -1,7 +1,9 @@ # FastAPI web server for MacroPad +import io import os import sys +import uuid import hmac import asyncio from typing import List, Optional @@ -11,10 +13,23 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, H from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from pydantic import BaseModel +from PIL import Image import uvicorn from config import DEFAULT_PORT, VERSION +# Max accepted upload size for images (bytes). +MAX_IMAGE_UPLOAD_BYTES = 5 * 1024 * 1024 + +# Map Pillow image formats to safe file extensions we're willing to persist. +_IMAGE_FORMAT_EXT = { + "PNG": ".png", + "JPEG": ".jpg", + "GIF": ".gif", + "BMP": ".bmp", + "WEBP": ".webp", +} + def get_resource_path(relative_path): """Get the path to a bundled resource file.""" @@ -73,12 +88,16 @@ class ConnectionManager: self.active_connections.remove(websocket) async def broadcast(self, message: dict): - """Send message to all connected clients.""" - for connection in self.active_connections: + """Send message to all connected clients, pruning dead sockets.""" + dead = [] + # Iterate over a snapshot so the list isn't mutated during iteration. + for connection in list(self.active_connections): try: await connection.send_json(message) except Exception: - pass + dead.append(connection) + for connection in dead: + self.disconnect(connection) class WebServer: @@ -190,7 +209,12 @@ class WebServer: @app.post("/api/execute") async def execute_macro(request: ExecuteRequest): """Execute a macro by ID.""" - success = self.macro_manager.execute_macro(request.macro_id) + # execute_macro is synchronous and may sleep (wait steps); run it in + # a thread executor so it doesn't block the event loop / other traffic. + loop = asyncio.get_event_loop() + success = await loop.run_in_executor( + None, self.macro_manager.execute_macro, request.macro_id + ) if success: # Broadcast execution to all connected clients await self.manager.broadcast({ @@ -239,18 +263,60 @@ class WebServer: @app.post("/api/upload-image") async def upload_image(file: UploadFile = File(...)): - """Upload an image for a macro.""" - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="File must be an image") + """Upload an image for a macro. - # Save to temp location - import tempfile - import shutil + Hardened: enforces a size cap, validates the bytes are a real image + with Pillow (content_type is untrusted), and saves into the app's + macro_images/ directory under a server-generated uuid name. Returns + only a relative reference, never an absolute server path. + """ + # Read with a hard size cap (reject oversize before buffering it all). + data = b"" + chunk_size = 64 * 1024 + while True: + chunk = await file.read(chunk_size) + if not chunk: + break + data += chunk + if len(data) > MAX_IMAGE_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Image too large") - ext = os.path.splitext(file.filename)[1] if file.filename else ".png" - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: - shutil.copyfileobj(file.file, tmp) - return {"path": tmp.name} + if not data: + raise HTTPException(status_code=400, detail="Empty upload") + + # Validate it's really an image (don't trust content_type). + try: + Image.open(io.BytesIO(data)).verify() + # verify() leaves the image unusable; reopen to read the format. + img_format = (Image.open(io.BytesIO(data)).format or "").upper() + except Exception: + raise HTTPException(status_code=400, detail="File is not a valid image") + + ext = _IMAGE_FORMAT_EXT.get(img_format) + if not ext: + raise HTTPException(status_code=400, detail="Unsupported image format") + + images_dir = os.path.join(self.app_dir, "macro_images") + os.makedirs(images_dir, exist_ok=True) + + filename = f"{uuid.uuid4().hex}{ext}" + dest_path = os.path.join(images_dir, filename) + rel_path = os.path.join("macro_images", filename) + + try: + with open(dest_path, "wb") as out: + out.write(data) + except Exception: + # Clean up a partial file on failure. + try: + if os.path.exists(dest_path): + os.remove(dest_path) + except OSError: + pass + raise HTTPException(status_code=500, detail="Failed to save image") + + # Return only a relative, opaque reference. + return {"path": rel_path.replace(os.sep, "/")} @app.get("/api/image/{image_path:path}") async def get_image(image_path: str): @@ -276,11 +342,18 @@ class WebServer: await self.manager.connect(websocket) try: while True: + # receive_json raises on malformed input / non-JSON frames; + # any such error must still tear the connection down cleanly. data = await websocket.receive_json() # Handle incoming messages if needed - if data.get("type") == "ping": + if isinstance(data, dict) and data.get("type") == "ping": await websocket.send_json({"type": "pong"}) except WebSocketDisconnect: + pass + except Exception: + # Malformed input or any other error: don't leak the connection. + pass + finally: self.manager.disconnect(websocket) self.app = app From 01163891ec68339cdcd82995ed4624d7a0460d40 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:32:04 -0700 Subject: [PATCH 05/11] design(web): "Tactile Deck" redesign with light/dark theming - Full semantic design-token layer (surfaces, spacing scale, radii, elevation, type scale) as the single source of truth; electric-violet signature accent instead of default blue. - Light + dark themes via prefers-color-scheme; muted text now meets WCAG AA (replaces low-contrast #aaa/#888). - Macro cards read as physical keys (raised, hairline highlight, per-macro --macro-color wash + placeholder, press/executing feedback, 2-line clamp). - Proper modal sheet (blurred scrim, sticky header/footer, focus rings), thumb-reachable floating action button, 44px tap targets, app-wide :focus-visible, and prefers-reduced-motion support. - manifest + theme-color meta updated to the new palette. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/css/styles.css | 1096 ++++++++++++++++++++++++++++---------------- web/index.html | 5 +- web/manifest.json | 5 +- 3 files changed, 702 insertions(+), 404 deletions(-) diff --git a/web/css/styles.css b/web/css/styles.css index a878f76..f194dd7 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -1,605 +1,901 @@ -/* MacroPad PWA Styles */ +/* ========================================================================== + MacroPad PWA — "Tactile Deck" + A macro deck that reads like a slab of physical keys: warm near-black + surfaces, an electric-violet signature accent, softly-raised cards with a + hairline top highlight, and a satisfying pressed state. + Self-contained (no external fonts/CDNs — CSP forbids them). + ========================================================================== */ +/* ---- Design tokens -------------------------------------------------------- + Everything downstream references these. Dark is the default; the + prefers-color-scheme: light block below overrides only the surface/text/ + border tokens so both themes stay deliberate and pass WCAG AA. */ :root { - --bg-color: #2e2e2e; - --fg-color: #ffffff; - --highlight-color: #3e3e3e; - --accent-color: #007acc; - --button-bg: #505050; - --button-hover: #606060; - --tab-bg: #404040; - --tab-selected: #007acc; - --danger-color: #dc3545; - --success-color: #28a745; - --warning-color: #ffc107; + color-scheme: dark; + + /* Surfaces — warm near-black, not a flat grey */ + --surface: #15161a; /* app background */ + --surface-raised: #1f2027; /* cards, header, sheets */ + --surface-sunken: #101116; /* inset wells: inputs, command list */ + --border: #2c2e38; /* hairline dividers */ + --border-strong: #3a3d4a; + + /* Text — muted meets 4.5:1 on --surface */ + --text: #f3f4f8; + --text-muted: #a9adbd; + + /* Signature accent — electric violet */ + --accent: #7c5cff; + --accent-hover: #8f73ff; + --accent-2: #ffb057; /* warm amber companion (wake glow, highlights) */ + --accent-contrast: #ffffff; + --accent-soft: rgba(124, 92, 255, 0.16); + + /* Status */ + --danger: #ff5d6c; + --success: #3ddc84; + --warning: #ffc861; + + /* Spacing scale — 4px base */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + + /* Radii */ + --r-sm: 8px; + --r-md: 12px; + --r-lg: 18px; + --r-card: 14px; + --r-pill: 999px; + + /* Elevation — soft, warm-tinted shadows */ + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-2: 0 6px 16px rgba(0, 0, 0, 0.40); + --shadow-3: 0 18px 48px rgba(0, 0, 0, 0.55); + + /* Hairline top highlight that makes surfaces read as physical keys */ + --key-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06); + + /* Type scale */ + --fs-xs: 0.75rem; + --fs-sm: 0.85rem; + --fs-base: 1rem; + --fs-lg: 1.2rem; + --fs-xl: 1.6rem; + + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', + Menlo, Consolas, monospace; + + --tap: 44px; /* minimum comfortable touch target */ + --dur: 0.18s; + --ease: cubic-bezier(0.2, 0.7, 0.2, 1); } +/* ---- Light theme — clean, deliberate, AA-compliant --------------------- */ +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + + --surface: #eceef4; + --surface-raised: #ffffff; + --surface-sunken: #e3e6ef; + --border: #d7dbe6; + --border-strong: #c3c8d6; + + --text: #191b22; + --text-muted: #5a5f70; /* 4.5:1+ on white and on --surface */ + + --accent: #5b3ee6; /* darkened so white text clears AA */ + --accent-hover: #4d31d6; + --accent-2: #c9720a; + --accent-soft: rgba(91, 62, 230, 0.12); + + --danger: #d13342; + --success: #17924f; + --warning: #a5730a; + + --shadow-1: 0 1px 2px rgba(20, 22, 40, 0.10); + --shadow-2: 0 6px 16px rgba(20, 22, 40, 0.12); + --shadow-3: 0 18px 48px rgba(20, 22, 40, 0.20); + --key-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9); + } +} + +/* ---- Reset ------------------------------------------------------------- */ * { box-sizing: border-box; margin: 0; padding: 0; } +/* [hidden] must always win over any display rule (keeps the modal closed). */ +[hidden] { + display: none !important; +} + +html { + -webkit-text-size-adjust: 100%; +} + body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background-color: var(--bg-color); - color: var(--fg-color); + font-family: var(--font-ui); + background-color: var(--surface); + color: var(--text); + font-size: var(--fs-base); + line-height: 1.45; min-height: 100vh; min-height: 100dvh; overflow-x: hidden; - /* Safe area for notched devices */ + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; padding-top: env(safe-area-inset-top); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); } -/* Header */ +/* App-wide keyboard focus ring (the old CSS had none). */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--r-sm); +} +:focus:not(:focus-visible) { + outline: none; +} + +/* ---- Header ------------------------------------------------------------ */ .header { - background-color: var(--highlight-color); - padding: 1rem; + background: + linear-gradient(180deg, rgba(124, 92, 255, 0.10), transparent 62%), + var(--surface-raised); + padding: var(--s-4); display: flex; justify-content: space-between; align-items: center; + gap: var(--s-3); position: sticky; top: 0; z-index: 100; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-1); } .header h1 { - font-size: 1.5rem; - font-weight: 600; + font-size: var(--fs-xl); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1; + background: linear-gradient(105deg, var(--text) 30%, var(--accent)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: var(--accent); /* fallback if clip unsupported */ } .header-actions { display: flex; - gap: 0.5rem; + align-items: center; + gap: var(--s-2); } +/* Header buttons */ .header-btn { - background: var(--accent-color); - color: white; + background: var(--accent); + color: var(--accent-contrast); border: none; - padding: 0.5rem 1rem; - border-radius: 4px; + padding: 0 var(--s-4); + min-height: var(--tap); + border-radius: var(--r-sm); cursor: pointer; - font-size: 0.9rem; - transition: background 0.2s; -} - -.header-btn:hover { - background: #0096ff; + font-size: var(--fs-sm); + font-weight: 600; + box-shadow: var(--shadow-1), var(--key-highlight); + transition: background var(--dur) var(--ease), transform var(--dur) var(--ease); } +.header-btn:hover { background: var(--accent-hover); } +.header-btn:active { transform: translateY(1px); } .header-btn.secondary { - background: var(--button-bg); + background: var(--surface-sunken); + color: var(--text); + border: 1px solid var(--border-strong); +} +.header-btn.secondary:hover { border-color: var(--accent); color: var(--text); } + +/* Square icon controls (fullscreen) — meet 44px tap target */ +.header-btn.icon-btn { + padding: 0; + width: var(--tap); + height: var(--tap); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--fs-lg); + line-height: 1; } -.header-btn.secondary:hover { - background: var(--button-hover); +/* ---- Connection status ------------------------------------------------- */ +.connection-status { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--fs-xs); + font-weight: 600; + color: var(--text-muted); + padding: 0 var(--s-2); + white-space: nowrap; } -/* Tabs */ +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 0 rgba(255, 93, 108, 0.5); + transition: background var(--dur) var(--ease); +} +.status-dot.connected { + background: var(--success); + animation: pulse-ring 2.4s ease-out infinite; +} +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 rgba(61, 220, 132, 0.5); } + 70% { box-shadow: 0 0 0 7px rgba(61, 220, 132, 0); } + 100% { box-shadow: 0 0 0 0 rgba(61, 220, 132, 0); } +} + +/* ---- Wake-lock toggle -------------------------------------------------- */ +.wake-lock-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--tap); + height: var(--tap); + background: var(--surface-sunken); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: var(--fs-base); + cursor: pointer; + opacity: 0.55; + transition: opacity var(--dur) var(--ease), border-color var(--dur) var(--ease); +} +.wake-lock-status:hover { opacity: 0.85; } +.wake-lock-status.active { + opacity: 1; + border-color: var(--accent-2); +} +.wake-lock-status .wake-icon { color: var(--accent-2); line-height: 1; } +.wake-lock-status.active .wake-icon { + animation: wake-glow 2s ease-in-out infinite; +} +.wake-lock-status.unsupported { opacity: 0.35; cursor: not-allowed; } +.wake-lock-status.unsupported .wake-icon { + color: var(--text-muted); + text-decoration: line-through; +} +@keyframes wake-glow { + 0%, 100% { filter: drop-shadow(0 0 0 rgba(255, 176, 87, 0)); opacity: 1; } + 50% { filter: drop-shadow(0 0 6px rgba(255, 176, 87, 0.7)); opacity: 0.8; } +} + +/* ---- Tabs -------------------------------------------------------------- */ .tabs { display: flex; - gap: 0.25rem; - padding: 0.5rem 1rem; - background: var(--bg-color); + gap: var(--s-2); + padding: var(--s-3) var(--s-4); + background: var(--surface); overflow-x: auto; -webkit-overflow-scrolling: touch; + scrollbar-width: none; } +.tabs::-webkit-scrollbar { display: none; } .tab { - background: var(--tab-bg); - color: var(--fg-color); - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; + background: var(--surface-raised); + color: var(--text-muted); + border: 1px solid var(--border); + padding: 0 var(--s-4); + min-height: var(--tap); + border-radius: var(--r-pill); cursor: pointer; white-space: nowrap; - transition: background 0.2s; + font-size: var(--fs-sm); + font-weight: 600; + transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease), + background var(--dur) var(--ease); } - -.tab:hover { - background: var(--button-hover); -} - +.tab:hover { color: var(--text); border-color: var(--border-strong); } .tab.active { - background: var(--tab-selected); + background: var(--accent-soft); + color: var(--text); + border-color: var(--accent); } -/* Macro Grid */ +/* ---- Macro grid -------------------------------------------------------- */ .macro-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 1rem; - padding: 1rem; - padding-bottom: calc(1rem + env(safe-area-inset-bottom)); + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: var(--s-4); + padding: var(--s-4); + padding-bottom: calc(var(--s-6) + env(safe-area-inset-bottom)); } +/* Cards read as physical keys: raised slab, hairline top highlight, + satisfying press. */ .macro-card { - background: var(--button-bg); - border-radius: 8px; - padding: 1rem; + position: relative; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--r-card); + padding: var(--s-4) var(--s-3) var(--s-3); display: flex; flex-direction: column; align-items: center; + gap: var(--s-3); cursor: pointer; - transition: transform 0.1s, background 0.2s; - min-height: 120px; + min-height: 148px; + color: var(--text); + box-shadow: var(--shadow-2), var(--key-highlight); + transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease), + border-color var(--dur) var(--ease); +} +/* Faint wash of the macro's own color across the top of the key */ +.macro-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(180deg, + color-mix(in srgb, var(--macro-color, var(--accent)) 22%, transparent), + transparent 45%); + opacity: 0.55; + pointer-events: none; } - .macro-card:hover { - background: var(--button-hover); - transform: translateY(-2px); + transform: translateY(-3px); + box-shadow: var(--shadow-3), var(--key-highlight); + border-color: var(--border-strong); } - .macro-card:active { - transform: translateY(0); + transform: translateY(1px) scale(0.985); + box-shadow: var(--shadow-1), var(--key-highlight); +} +.macro-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } +/* Pressed / executing — accent ring + brief success flash */ .macro-card.executing { - animation: pulse 0.3s ease-in-out; + animation: card-press 0.32s var(--ease); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft), var(--shadow-2); } - -@keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(0.95); background: var(--accent-color); } +@keyframes card-press { + 0% { transform: scale(1); } + 35% { transform: scale(0.94); } + 55% { box-shadow: 0 0 0 4px rgba(61, 220, 132, 0.35), var(--shadow-2); } 100% { transform: scale(1); } } .macro-image { - width: 64px; - height: 64px; + width: 68px; + height: 68px; object-fit: contain; - margin-bottom: 0.5rem; - border-radius: 4px; -} - -.macro-image-placeholder { - width: 64px; - height: 64px; - background: var(--highlight-color); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 0.5rem; - font-size: 1.5rem; -} - -.macro-name { - text-align: center; - font-size: 0.9rem; - word-break: break-word; -} - -.macro-edit-btn { - position: absolute; - top: 4px; - right: 4px; - background: rgba(0, 0, 0, 0.5); - color: white; - border: none; - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - opacity: 0; - transition: opacity 0.2s; -} - -.macro-card { + border-radius: var(--r-md); position: relative; } -.macro-card:hover .macro-edit-btn { - opacity: 1; -} - -/* Modal */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); +/* Placeholder background = the per-card --macro-color set by app.js */ +.macro-image-placeholder { + position: relative; + width: 68px; + height: 68px; + background: var(--macro-color, var(--accent)); + border-radius: var(--r-md); display: flex; align-items: center; justify-content: center; - z-index: 200; - padding: 1rem; + font-size: 1.9rem; + font-weight: 700; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + box-shadow: var(--shadow-1), inset 0 1px 0 rgba(255, 255, 255, 0.25); } +.macro-name { + position: relative; + text-align: center; + font-size: var(--fs-sm); + font-weight: 600; + line-height: 1.3; + color: var(--text); + /* 2-line clamp with ellipsis — no mid-word breaks */ + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; +} + +/* Edit affordance — always faintly visible; solid on hover/focus */ +.macro-edit-btn { + position: absolute; + top: var(--s-2); + right: var(--s-2); + z-index: 1; + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--surface-sunken) 82%, transparent); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + font-size: var(--fs-sm); + opacity: 0; + transition: opacity var(--dur) var(--ease), color var(--dur) var(--ease), + border-color var(--dur) var(--ease); +} +.macro-card:hover .macro-edit-btn, +.macro-card:focus-within .macro-edit-btn { opacity: 1; } +.macro-edit-btn:hover { color: var(--text); border-color: var(--accent); } +.macro-edit-btn:focus-visible { opacity: 1; } + +/* ---- Floating action button -------------------------------------------- */ +.fab { + position: fixed; + right: calc(var(--s-5) + env(safe-area-inset-right)); + bottom: calc(var(--s-5) + env(safe-area-inset-bottom)); + z-index: 150; + width: 60px; + height: 60px; + border: none; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-size: 2rem; + font-weight: 300; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 24px rgba(124, 92, 255, 0.45), var(--key-highlight); + transition: transform var(--dur) var(--ease), background var(--dur) var(--ease), + box-shadow var(--dur) var(--ease); +} +.fab:hover { + background: var(--accent-hover); + transform: translateY(-2px) scale(1.04); + box-shadow: 0 12px 30px rgba(124, 92, 255, 0.55), var(--key-highlight); +} +.fab:active { transform: translateY(0) scale(0.96); } + +/* ---- Modal / editor sheet ---------------------------------------------- */ +/* .modal is the fixed scrim; .modal-content is the sheet. */ .modal { - background: var(--highlight-color); - border-radius: 8px; + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: var(--s-4); + background: rgba(8, 9, 14, 0.6); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + animation: fade-in var(--dur) var(--ease); +} +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +.modal-content { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--r-lg); width: 100%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; + max-width: 520px; + max-height: min(90vh, 780px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: var(--shadow-3), var(--key-highlight); + animation: sheet-rise var(--dur) var(--ease); +} +@keyframes sheet-rise { + from { transform: translateY(12px) scale(0.98); opacity: 0; } + to { transform: none; opacity: 1; } } .modal-header { display: flex; justify-content: space-between; align-items: center; - padding: 1rem; - border-bottom: 1px solid var(--bg-color); + padding: var(--s-4) var(--s-5); + border-bottom: 1px solid var(--border); + background: var(--surface-raised); + position: sticky; + top: 0; } - .modal-header h2 { - font-size: 1.2rem; + font-size: var(--fs-lg); + font-weight: 700; + letter-spacing: -0.01em; } .modal-close { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; background: none; border: none; - color: var(--fg-color); - font-size: 1.5rem; + color: var(--text-muted); + font-size: 1.6rem; + line-height: 1; cursor: pointer; - padding: 0.25rem; + border-radius: 50%; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease); } +.modal-close:hover { background: var(--surface-sunken); color: var(--text); } .modal-body { - padding: 1rem; + padding: var(--s-5); + overflow-y: auto; + flex: 1; } .modal-footer { display: flex; justify-content: flex-end; - gap: 0.5rem; - padding: 1rem; - border-top: 1px solid var(--bg-color); + gap: var(--s-3); + padding: var(--s-4) var(--s-5); + border-top: 1px solid var(--border); + background: var(--surface-raised); + position: sticky; + bottom: 0; } +/* Push Delete to the far left when present */ +.modal-footer .btn-danger { margin-right: auto; } -/* Form Elements */ -.form-group { - margin-bottom: 1rem; -} +/* ---- Form elements ----------------------------------------------------- */ +.form-group { margin-bottom: var(--s-5); } +.form-group:last-child { margin-bottom: 0; } .form-group label { display: block; - margin-bottom: 0.25rem; - font-size: 0.9rem; - color: #aaa; + margin-bottom: var(--s-2); + font-size: var(--fs-sm); + font-weight: 600; + color: var(--text-muted); } .form-group input, .form-group select { width: 100%; - padding: 0.5rem; - background: var(--bg-color); - border: 1px solid var(--button-bg); - border-radius: 4px; - color: var(--fg-color); - font-size: 1rem; + padding: var(--s-3); + min-height: var(--tap); + background: var(--surface-sunken); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-family: inherit; + font-size: var(--fs-base); + transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); } - +.form-group input::placeholder { color: var(--text-muted); opacity: 0.8; } .form-group input:focus, .form-group select:focus { outline: none; - border-color: var(--accent-color); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } -/* Command Builder */ +/* ---- Command builder --------------------------------------------------- */ .command-list { - background: var(--bg-color); - border-radius: 4px; - padding: 0.5rem; - min-height: 100px; - max-height: 300px; - overflow-y: auto; + background: var(--surface-sunken); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--s-3); + min-height: 96px; + display: flex; + flex-direction: column; + gap: var(--s-2); } .command-item { - background: var(--button-bg); - border-radius: 4px; - padding: 0.5rem; - margin-bottom: 0.5rem; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: var(--s-2); display: flex; align-items: center; - gap: 0.5rem; + gap: var(--s-2); } -.command-item:last-child { - margin-bottom: 0; +.command-type-select { + flex: 0 0 auto; + min-height: 40px; + padding: 0 var(--s-2); + background: var(--surface-sunken); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-family: inherit; + font-size: var(--fs-sm); + font-weight: 600; + cursor: pointer; } - -.command-type { - background: var(--accent-color); - color: white; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - text-transform: uppercase; - min-width: 50px; - text-align: center; +.command-type-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } .command-value { - flex: 1; - font-family: monospace; - font-size: 0.9rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex: 1 1 auto; + min-width: 0; + min-height: 40px; + padding: 0 var(--s-3); + background: var(--surface-sunken); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-sm); } +.command-value:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.command-value::placeholder { color: var(--text-muted); opacity: 0.7; } .command-actions { display: flex; - gap: 0.25rem; + gap: var(--s-1); + flex: 0 0 auto; } - .command-actions button { - background: var(--highlight-color); - border: none; - color: var(--fg-color); - padding: 0.25rem 0.5rem; - border-radius: 4px; + width: 34px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface-sunken); + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: var(--r-sm); cursor: pointer; - font-size: 0.8rem; -} - -.command-actions button:hover { - background: var(--button-hover); -} - -.command-actions button.delete { - color: var(--danger-color); + font-size: var(--fs-sm); + transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease); } +.command-actions button:hover { color: var(--text); border-color: var(--border-strong); } +.command-actions button.delete:hover { color: var(--danger); border-color: var(--danger); } .add-command-btns { display: flex; flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--s-2); + margin-top: var(--s-3); } - .add-command-btn { - background: var(--button-bg); - border: none; - color: var(--fg-color); - padding: 0.5rem 0.75rem; - border-radius: 4px; + background: transparent; + border: 1px dashed var(--border-strong); + color: var(--text-muted); + padding: var(--s-2) var(--s-3); + min-height: 40px; + border-radius: var(--r-sm); cursor: pointer; - font-size: 0.85rem; - transition: background 0.2s; + font-size: var(--fs-sm); + font-weight: 600; + transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease), + background var(--dur) var(--ease); } - .add-command-btn:hover { - background: var(--accent-color); + color: var(--accent); + border-color: var(--accent); + border-style: solid; + background: var(--accent-soft); } -/* Buttons */ +/* ---- Buttons ----------------------------------------------------------- */ .btn { - padding: 0.5rem 1rem; - border: none; - border-radius: 4px; + padding: 0 var(--s-5); + min-height: var(--tap); + border: 1px solid transparent; + border-radius: var(--r-sm); cursor: pointer; - font-size: 0.9rem; - transition: background 0.2s; + font-family: inherit; + font-size: var(--fs-sm); + font-weight: 600; + box-shadow: var(--key-highlight); + transition: background var(--dur) var(--ease), transform var(--dur) var(--ease), + border-color var(--dur) var(--ease); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-primary { - background: var(--accent-color); - color: white; -} - -.btn-primary:hover { - background: #0096ff; -} +.btn-primary { background: var(--accent); color: var(--accent-contrast); } +.btn-primary:hover { background: var(--accent-hover); } .btn-secondary { - background: var(--button-bg); - color: white; + background: var(--surface-sunken); + color: var(--text); + border-color: var(--border-strong); } +.btn-secondary:hover { border-color: var(--accent); } -.btn-secondary:hover { - background: var(--button-hover); -} +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { filter: brightness(1.08); } -.btn-danger { - background: var(--danger-color); - color: white; -} - -.btn-danger:hover { - background: #c82333; -} - -/* Status/Toast Messages */ +/* ---- Toasts ------------------------------------------------------------ */ .toast-container { position: fixed; - bottom: calc(1rem + env(safe-area-inset-bottom)); - right: 1rem; + bottom: calc(var(--s-4) + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); z-index: 300; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-2); + width: max-content; + max-width: calc(100vw - 2 * var(--s-4)); + pointer-events: none; } .toast { - background: var(--highlight-color); - color: var(--fg-color); - padding: 0.75rem 1rem; - border-radius: 4px; - margin-top: 0.5rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - animation: slideIn 0.3s ease-out; + pointer-events: auto; + background: var(--surface-raised); + color: var(--text); + padding: var(--s-3) var(--s-4); + border: 1px solid var(--border); + border-left-width: 4px; + border-radius: var(--r-sm); + font-size: var(--fs-sm); + font-weight: 500; + box-shadow: var(--shadow-2); + animation: toast-in var(--dur) var(--ease); +} +.toast.success { border-left-color: var(--success); } +.toast.error { border-left-color: var(--danger); } +@keyframes toast-in { + from { transform: translateY(12px); opacity: 0; } + to { transform: none; opacity: 1; } } -.toast.success { - border-left: 4px solid var(--success-color); -} - -.toast.error { - border-left: 4px solid var(--danger-color); -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -/* Connection Status */ -.connection-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8rem; - color: #aaa; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--danger-color); -} - -.status-dot.connected { - background: var(--success-color); -} - -/* Empty State */ +/* ---- Empty state ------------------------------------------------------- */ .empty-state { + grid-column: 1 / -1; text-align: center; - padding: 3rem 1rem; - color: #888; + padding: var(--s-6) var(--s-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-3); +} +/* Large signature glyph */ +.empty-state::before { + content: "⌘"; + font-size: 4rem; + line-height: 1; + color: var(--accent); + opacity: 0.85; + margin-bottom: var(--s-2); } - .empty-state p { - margin-bottom: 1rem; + font-size: var(--fs-lg); + font-weight: 700; + color: var(--text); } +.empty-state p.hint { + font-size: var(--fs-sm); + font-weight: 400; + color: var(--text-muted); + max-width: 32ch; +} +.empty-state .btn { margin-top: var(--s-3); } -/* Loading */ +/* ---- Loading ----------------------------------------------------------- */ .loading { + grid-column: 1 / -1; display: flex; justify-content: center; - padding: 2rem; + padding: var(--s-6); } - .spinner { - width: 40px; - height: 40px; - border: 3px solid var(--button-bg); - border-top-color: var(--accent-color); + width: 42px; + height: 42px; + border: 3px solid var(--border-strong); + border-top-color: var(--accent); border-radius: 50%; - animation: spin 1s linear infinite; + animation: spin 0.9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Mobile Optimizations */ -@media (max-width: 480px) { - .header h1 { - font-size: 1.2rem; - } - - .macro-grid { - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 0.75rem; - padding: 0.75rem; - } - - .macro-card { - padding: 0.75rem; - min-height: 100px; - } - - .macro-image, - .macro-image-placeholder { - width: 48px; - height: 48px; - } - - .modal { - max-width: 100%; - margin: 0.5rem; - } -} - -/* Install Banner */ +/* ---- Install banner ---------------------------------------------------- */ .install-banner { - background: var(--accent-color); - color: white; - padding: 0.75rem 1rem; display: flex; justify-content: space-between; align-items: center; + gap: var(--s-3); + padding: var(--s-3) var(--s-4); + background: var(--accent-soft); + color: var(--text); + border-bottom: 1px solid var(--accent); + font-size: var(--fs-sm); + font-weight: 500; } - +.install-banner > div { display: flex; gap: var(--s-2); align-items: center; } .install-banner button { - background: white; - color: var(--accent-color); + background: var(--accent); + color: var(--accent-contrast); border: none; - padding: 0.5rem 1rem; - border-radius: 4px; + padding: var(--s-2) var(--s-4); + min-height: 40px; + border-radius: var(--r-sm); cursor: pointer; font-weight: 600; } - +.install-banner button:hover { background: var(--accent-hover); } .install-banner .dismiss { background: transparent; - color: white; - padding: 0.5rem; + color: var(--text-muted); + padding: var(--s-2); + min-width: 40px; +} +.install-banner .dismiss:hover { color: var(--text); } + +/* ---- Fullscreen tweaks ------------------------------------------------- */ +:fullscreen .header { padding-top: var(--s-2); padding-bottom: var(--s-2); } +:fullscreen .macro-grid { padding-bottom: var(--s-4); } + +/* ---- Mobile ------------------------------------------------------------ */ +@media (max-width: 480px) { + .header { padding: var(--s-3); } + .header h1 { font-size: var(--fs-lg); } + /* Keep the status text from crowding; dot + label still fit */ + .connection-status { font-size: 0.7rem; padding: 0; } + + .macro-grid { + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: var(--s-3); + padding: var(--s-3); + } + .macro-card { min-height: 128px; padding: var(--s-3) var(--s-2) var(--s-2); } + .macro-image, + .macro-image-placeholder { width: 56px; height: 56px; font-size: 1.6rem; } + + .modal { padding: var(--s-2); align-items: flex-end; } + .modal-content { max-width: 100%; max-height: 92vh; } + .modal-body { padding: var(--s-4); } + .modal-header, .modal-footer { padding: var(--s-4); } + + .fab { + right: calc(var(--s-4) + env(safe-area-inset-right)); + bottom: calc(var(--s-4) + env(safe-area-inset-bottom)); + } } -/* Fullscreen Button */ -.header-btn.icon-btn { - padding: 0.5rem 0.75rem; - font-size: 1.2rem; - line-height: 1; -} - -/* Wake Lock Status */ -.wake-lock-status { - display: flex; - align-items: center; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 1rem; - opacity: 0.4; - transition: opacity 0.3s; -} - -.wake-lock-status.active { - opacity: 1; -} - -.wake-lock-status .wake-icon { - color: #ffc107; -} - -.wake-lock-status.active .wake-icon { - animation: pulse-glow 2s ease-in-out infinite; -} - -.wake-lock-status.unsupported { - opacity: 0.3; -} - -.wake-lock-status.unsupported .wake-icon { - color: #888; - text-decoration: line-through; -} - -@keyframes pulse-glow { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } -} - -/* Fullscreen styles */ -:fullscreen .header { - padding-top: 0.5rem; -} - -:fullscreen .macro-grid { - padding-bottom: 1rem; +/* ---- Reduced motion ---------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + .macro-card:hover { transform: none; } } diff --git a/web/index.html b/web/index.html index 38e22f2..6d3fdfb 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,8 @@ - + + @@ -13,7 +14,7 @@ - + MacroPad diff --git a/web/manifest.json b/web/manifest.json index f2a0b7d..0f5219b 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -6,9 +6,10 @@ "start_url": "/", "scope": "/", "display": "standalone", - "background_color": "#2e2e2e", - "theme_color": "#007acc", + "background_color": "#15161a", + "theme_color": "#7c5cff", "orientation": "any", + "//": "Maskable icons ideally need ~10% transparent padding so they aren't cropped inside the platform safe zone; the current PNGs are reused as-is without new art.", "icons": [ { "src": "/static/icons/icon-192.png", From 5896ec0893b362c6081e5d664ce1adb77cf346d5 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:37:25 -0700 Subject: [PATCH 06/11] security(web): enforce anti-clickjacking via response headers frame-ancestors is ignored inside a CSP, so move clickjacking protection to real response headers (X-Frame-Options: DENY) and add X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on all responses. Drop the ineffective frame-ancestors token from the meta CSP. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/index.html | 2 +- web_server.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/web/index.html b/web/index.html index 6d3fdfb..5d087f8 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - + diff --git a/web_server.py b/web_server.py index 5ba71d6..9d0e10d 100644 --- a/web_server.py +++ b/web_server.py @@ -140,21 +140,32 @@ class WebServer: # Paths served without a token (PWA shell + static assets) public_paths = {"/", "/manifest.json", "/service-worker.js"} + def _add_security_headers(response): + # Anti-clickjacking / MIME-sniffing. frame-ancestors can only be set + # via a real response header (it is ignored inside a CSP), so + # we enforce it here rather than in index.html. + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + return response + @app.middleware("http") async def auth_middleware(request: Request, call_next): """Require a valid auth token for all non-public paths.""" path = request.url.path if path in public_paths or path.startswith("/static/"): - return await call_next(request) + return _add_security_headers(await call_next(request)) # Fail-open only when no token is configured (shouldn't happen in prod) if self.auth_token: provided = request.headers.get("X-MacroPad-Token") \ or request.query_params.get("token") or "" if not hmac.compare_digest(provided, self.auth_token): - return JSONResponse({"detail": "Unauthorized"}, status_code=401) + return _add_security_headers( + JSONResponse({"detail": "Unauthorized"}, status_code=401) + ) - return await call_next(request) + return _add_security_headers(await call_next(request)) @app.get("/", response_class=HTMLResponse) async def index(): From 0d6f48ca244d01dde8b93b98caa1b1c63d4f9e54 Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 10:39:16 -0700 Subject: [PATCH 07/11] docs: document auth token, no-shell app commands, web editor, media keys Update README for the new security model and features: local API access token embedded in the URL/QR (and web.allow_lan), app commands executing without a shell, relay password in OS keyring, web-based macro creation, and media/system keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ba72211..3ec1055 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A cross-platform macro management application with desktop and web interfaces. C - **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.) - **Wait**: Insert delays between commands (in milliseconds) - **App**: Launch applications or scripts +- **Media & System Keys**: Control playback and audio (volume up/down/mute, play/pause, next/previous track, stop) alongside regular keys - **Custom Images**: Assign images to macros for easy identification - **Category Management**: Organize macros into custom tabs @@ -119,15 +120,18 @@ A macro that types a username, waits, presses Tab, types a password, and presses ### Web Interface (PWA) 1. Start the application (web server starts automatically on port 40000) -2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`) +2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000/?token=...`) 3. Open this URL on any device on your network 4. **Install as PWA**: - Mobile: Tap browser menu → "Add to Home Screen" - Desktop: Click install icon in address bar +> [!IMPORTANT] +> The local web server now requires an access token. A per-install token is auto-generated and stored in `settings.json`, and is embedded in the URL shown in the toolbar and in the QR code — so copying the URL or scanning the QR just works. Anyone with that URL can control your macros, so treat it like a password. By default the server also binds to your LAN; set `web.allow_lan` to `false` in settings to restrict access to localhost only. + The web interface provides full macro management: - View and execute macros -- Create and edit macros with command builder +- Create, edit, and delete macros directly from the web app — tap the **+** button to open the command-sequence builder (no desktop app required) - Organize into categories - Real-time sync across devices @@ -154,7 +158,10 @@ Once connected, a relay URL will appear in the toolbar. Use this URL from any de - Wake lock and fullscreen mode > [!NOTE] -> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions. +> The relay password is stored in your OS keyring when one is available, falling back to the settings file (written with restricted `0600` permissions) otherwise. + +> [!NOTE] +> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions. The self-hosted relay now enforces login rate-limiting/lockout, keeps credentials out of URLs, and requires setting sane environment values — see the relay repo's `.env.example`. ## Command Types Reference @@ -164,10 +171,13 @@ Once connected, a relay URL will appear in the toolbar. Use this URL from any de | `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) | | `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) | | `wait` | Delays execution | `ms`: Milliseconds to wait | -| `app` | Launches application | `command`: Shell command to execute | +| `app` | Launches an application | `command`: Program and arguments to run (tokenized and executed directly — no shell) | ## Example Application Commands +> [!IMPORTANT] +> `app` commands are tokenized and the program is executed **directly**, without a shell. This closes a remote-code-execution risk, but it means shell features are **not** supported: command chaining (`;`, `&&`, `|`), output redirection (`>`), environment-variable expansion (`$VAR`, `%VAR%`), and shell built-ins will not work. Launching a program with arguments works exactly as shown below. + ### Windows ```bash # Steam game From d07005bde304b7fb4fdef981f35454264067d81d Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 18:07:48 -0700 Subject: [PATCH 08/11] security(relay): fix lockout DoS and DOM XSS in web client Addresses two Major findings from PR review: - Auth lockout no longer griefable: the throttle key is namespaced per path and includes the client IP (web:: vs desktop::), so a flood of bad web-client auths can neither lock other clients of the same session nor block the desktop from (re)authenticating. Per-socket failure close and brute-force lockout are preserved. - Relay web client XSS eliminated: app.html tab/macro rendering rebuilt with createElement/textContent (no macro/tab value reaches innerHTML); inline scripts/handlers externalized to /static/app.js and /static/login.js so the helmet CSP now uses script-src 'self' (dropped 'unsafe-inline' for scripts). Co-Authored-By: Claude Opus 4.8 (1M context) --- macropad-relay/public/app.html | 372 +--------------- macropad-relay/public/app.js | 405 ++++++++++++++++++ macropad-relay/public/login.html | 92 +--- macropad-relay/public/login.js | 91 ++++ macropad-relay/src/handlers/desktopHandler.ts | 20 +- .../src/handlers/webClientHandler.ts | 20 +- macropad-relay/src/server.ts | 13 +- macropad-relay/src/utils/authThrottle.ts | 12 +- 8 files changed, 543 insertions(+), 482 deletions(-) create mode 100644 macropad-relay/public/app.js create mode 100644 macropad-relay/public/login.js diff --git a/macropad-relay/public/app.html b/macropad-relay/public/app.html index d8fe592..2eacf99 100644 --- a/macropad-relay/public/app.html +++ b/macropad-relay/public/app.html @@ -264,8 +264,8 @@
- - + + @@ -277,372 +277,6 @@
- + diff --git a/macropad-relay/public/app.js b/macropad-relay/public/app.js new file mode 100644 index 0000000..6e61335 --- /dev/null +++ b/macropad-relay/public/app.js @@ -0,0 +1,405 @@ +// MacroPad App for Relay Mode (externalized so the CSP can drop +// 'unsafe-inline' for scripts). All macro/tab/id/name values are rendered +// with safe DOM APIs (createElement/textContent) and never via innerHTML. +class MacroPadApp { + constructor() { + this.macros = {}; + this.tabs = []; + this.currentTab = 'All'; + this.ws = null; + this.desktopConnected = false; + this.wsAuthenticated = false; + + // Get session ID from URL + const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/); + this.sessionId = pathMatch ? pathMatch[1] : null; + + // Get password from storage (never from the URL). + this.password = sessionStorage.getItem(`macropad_${this.sessionId}`) + || localStorage.getItem(`macropad_${this.sessionId}`); + + if (this.password) { + sessionStorage.setItem(`macropad_${this.sessionId}`, this.password); + } + + this.init(); + } + + async init() { + this.wakeLock = null; + this.wakeLockEnabled = false; + + this.setupPWA(); + this.setupWebSocket(); + this.setupEventListeners(); + this.setupWakeLock(); + } + + getApiHeaders() { + return { + 'Content-Type': 'application/json', + 'X-MacroPad-Password': this.password || '' + }; + } + + async loadTabs() { + try { + const response = await fetch(`/${this.sessionId}/api/tabs`, { + headers: this.getApiHeaders() + }); + if (response.status === 401) return this.handleAuthError(); + if (response.status === 503) return this.handleDesktopOffline(); + const data = await response.json(); + this.tabs = data.tabs || []; + this.renderTabs(); + } catch (error) { + console.error('Error loading tabs:', error); + } + } + + async loadMacros() { + try { + const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`; + const response = await fetch(`/${this.sessionId}${path}`, { + headers: this.getApiHeaders() + }); + if (response.status === 401) return this.handleAuthError(); + if (response.status === 503) return this.handleDesktopOffline(); + const data = await response.json(); + this.macros = data.macros || {}; + this.renderMacros(); + } catch (error) { + console.error('Error loading macros:', error); + } + } + + async executeMacro(macroId) { + const card = document.querySelector(`[data-macro-id="${macroId}"]`); + if (card) card.classList.add('executing'); + + try { + const response = await fetch(`/${this.sessionId}/api/execute`, { + method: 'POST', + headers: this.getApiHeaders(), + body: JSON.stringify({ macro_id: macroId }) + }); + if (!response.ok) throw new Error('Failed'); + } catch (error) { + this.showToast('Execution failed', 'error'); + } + + setTimeout(() => card?.classList.remove('executing'), 300); + } + + handleAuthError() { + sessionStorage.removeItem(`macropad_${this.sessionId}`); + localStorage.removeItem(`macropad_${this.sessionId}`); + window.location.href = `/${this.sessionId}`; + } + + handleDesktopOffline() { + this.desktopConnected = false; + this.updateConnectionStatus(false); + document.getElementById('offline-banner').classList.add('visible'); + } + + setupWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`; + + this.ws = new WebSocket(wsUrl); + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleMessage(data); + }; + + this.ws.onclose = () => { + this.wsAuthenticated = false; + this.updateConnectionStatus(false); + setTimeout(() => this.setupWebSocket(), 3000); + }; + + this.ws.onerror = () => this.updateConnectionStatus(false); + } + + handleMessage(data) { + switch (data.type) { + case 'auth_required': + if (this.password) { + this.ws.send(JSON.stringify({ type: 'auth', password: this.password })); + } + break; + case 'auth_response': + if (data.success) { + this.wsAuthenticated = true; + this.updateConnectionStatus(this.desktopConnected); + } else { + this.handleAuthError(); + } + break; + case 'desktop_status': + this.desktopConnected = data.status === 'connected'; + this.updateConnectionStatus(this.desktopConnected); + document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected); + if (this.desktopConnected) { + this.loadTabs(); + this.loadMacros(); + } + break; + case 'macro_created': + case 'macro_updated': + case 'macro_deleted': + this.loadTabs(); + this.loadMacros(); + break; + case 'executed': { + const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`); + if (card) { + card.classList.add('executing'); + setTimeout(() => card.classList.remove('executing'), 300); + } + break; + } + } + } + + updateConnectionStatus(connected) { + const dot = document.querySelector('.status-dot'); + const text = document.querySelector('.connection-status span'); + if (dot) dot.classList.toggle('connected', connected); + if (text) text.textContent = connected ? 'Connected' : 'Disconnected'; + } + + renderTabs() { + const container = document.getElementById('tabs-container'); + container.replaceChildren(); + for (const tab of this.tabs) { + const name = typeof tab === 'string' ? tab : String(tab); + const btn = document.createElement('button'); + btn.className = name === this.currentTab ? 'tab active' : 'tab'; + btn.setAttribute('data-tab', name); + btn.textContent = name; + container.appendChild(btn); + } + } + + renderMacros() { + const container = document.getElementById('macro-grid'); + const entries = Object.entries(this.macros); + + container.replaceChildren(); + + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + const p = document.createElement('p'); + p.textContent = 'No macros found'; + empty.appendChild(p); + container.appendChild(empty); + return; + } + + for (const [id, macro] of entries) { + const name = macro && typeof macro.name === 'string' ? macro.name : ''; + const hasImage = !!(macro && macro.image_path); + const firstChar = name.charAt(0).toUpperCase(); + + const card = document.createElement('div'); + card.className = 'macro-card'; + card.setAttribute('data-macro-id', id); + card.addEventListener('click', () => this.executeMacro(id)); + + if (hasImage) { + const img = document.createElement('img'); + img.className = 'macro-image'; + img.style.display = 'none'; + card.appendChild(img); + } + + const placeholder = document.createElement('div'); + placeholder.className = 'macro-image-placeholder'; + placeholder.textContent = firstChar; + card.appendChild(placeholder); + + const nameSpan = document.createElement('span'); + nameSpan.className = 'macro-name'; + nameSpan.textContent = name; + card.appendChild(nameSpan); + + container.appendChild(card); + } + + // Load macro images via authenticated fetch (header, not URL). + this.loadMacroImages(); + } + + // Fetch each macro image with the password header and display it + // as a blob object URL so the credential never appears in a URL. + async loadMacroImages() { + for (const [id, macro] of Object.entries(this.macros)) { + if (!macro.image_path) continue; + const card = document.querySelector(`[data-macro-id="${id}"]`); + if (!card) continue; + const img = card.querySelector('.macro-image'); + if (!img) continue; + + try { + const response = await fetch( + `/${this.sessionId}/api/image/${macro.image_path}`, + { headers: this.getApiHeaders() } + ); + if (!response.ok) continue; // keep placeholder + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => URL.revokeObjectURL(objectUrl); + img.src = objectUrl; + img.style.display = ''; + const placeholder = img.nextElementSibling; + if (placeholder) placeholder.style.display = 'none'; + } catch (error) { + // Leave the placeholder visible on failure. + } + } + } + + setupEventListeners() { + document.getElementById('tabs-container').addEventListener('click', (e) => { + if (e.target.classList.contains('tab')) { + this.currentTab = e.target.dataset.tab; + this.renderTabs(); + this.loadMacros(); + } + }); + + const fullscreenBtn = document.getElementById('fullscreen-btn'); + if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + + const refreshBtn = document.getElementById('refresh-btn'); + if (refreshBtn) refreshBtn.addEventListener('click', () => this.refresh()); + } + + showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 3000); + } + + refresh() { + this.loadTabs(); + this.loadMacros(); + } + + // Fullscreen + toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(err => { + console.log('Fullscreen error:', err); + }); + } else { + document.exitFullscreen(); + } + } + + // Wake Lock + async setupWakeLock() { + const status = document.getElementById('wake-lock-status'); + + if (!('wakeLock' in navigator)) { + console.log('Wake Lock API not supported'); + if (status) { + status.classList.add('unsupported'); + status.title = 'Wake lock not available (requires HTTPS)'; + } + return; + } + + if (status) { + status.style.cursor = 'pointer'; + status.addEventListener('click', () => this.toggleWakeLock()); + } + + await this.requestWakeLock(); + + document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible' && this.wakeLockEnabled) { + await this.requestWakeLock(); + } + }); + } + + async toggleWakeLock() { + if (this.wakeLock) { + await this.wakeLock.release(); + this.wakeLock = null; + this.wakeLockEnabled = false; + this.updateWakeLockStatus(false); + this.showToast('Screen can now sleep', 'info'); + } else { + this.wakeLockEnabled = true; + await this.requestWakeLock(); + if (this.wakeLock) { + this.showToast('Screen will stay awake', 'success'); + } + } + } + + async requestWakeLock() { + try { + this.wakeLock = await navigator.wakeLock.request('screen'); + this.wakeLockEnabled = true; + this.updateWakeLockStatus(true); + + this.wakeLock.addEventListener('release', () => { + this.updateWakeLockStatus(false); + }); + } catch (err) { + console.log('Wake Lock error:', err); + this.updateWakeLockStatus(false); + const status = document.getElementById('wake-lock-status'); + if (status && !status.classList.contains('unsupported')) { + status.title = 'Wake lock failed: ' + err.message; + } + } + } + + updateWakeLockStatus(active) { + const status = document.getElementById('wake-lock-status'); + if (status) { + status.classList.toggle('active', active); + if (!status.classList.contains('unsupported')) { + status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)'; + } + } + } + + // PWA manifest setup + setupPWA() { + // Create dynamic manifest for this session + const manifest = { + name: 'MacroPad', + short_name: 'MacroPad', + description: 'Remote macro control', + start_url: `/${this.sessionId}/app`, + display: 'standalone', + background_color: '#2e2e2e', + theme_color: '#007acc', + icons: [ + { src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' } + ] + }; + + const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' }); + const manifestUrl = URL.createObjectURL(blob); + document.getElementById('manifest-link').setAttribute('href', manifestUrl); + } +} + +let app; +document.addEventListener('DOMContentLoaded', () => { + app = new MacroPadApp(); +}); diff --git a/macropad-relay/public/login.html b/macropad-relay/public/login.html index 159ac6e..ab6397e 100644 --- a/macropad-relay/public/login.html +++ b/macropad-relay/public/login.html @@ -158,96 +158,6 @@

Checking connection...

- + diff --git a/macropad-relay/public/login.js b/macropad-relay/public/login.js new file mode 100644 index 0000000..dd224f5 --- /dev/null +++ b/macropad-relay/public/login.js @@ -0,0 +1,91 @@ +// MacroPad login page logic (externalized so the CSP can drop 'unsafe-inline' +// for scripts). +const sessionId = window.location.pathname.split('/')[1]; +const form = document.getElementById('loginForm'); +const passwordInput = document.getElementById('password'); +const rememberCheckbox = document.getElementById('remember'); +const submitBtn = document.getElementById('submitBtn'); +const errorDiv = document.getElementById('error'); +const statusDiv = document.getElementById('status'); + +let desktopConnected = false; + +// Check for saved password (session first, then "remembered" store) +const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`) + || localStorage.getItem(`macropad_${sessionId}`); +if (savedPassword) { + passwordInput.value = savedPassword; +} + +// Connect to WebSocket to check desktop status +function checkStatus() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'desktop_status') { + desktopConnected = data.status === 'connected'; + updateStatus(); + } + }; + + ws.onerror = () => { + statusDiv.textContent = 'Connection error'; + statusDiv.className = 'status disconnected'; + }; + + ws.onclose = () => { + setTimeout(checkStatus, 5000); + }; +} + +function updateStatus() { + if (desktopConnected) { + statusDiv.textContent = 'Desktop connected'; + statusDiv.className = 'status connected'; + submitBtn.disabled = false; + } else { + statusDiv.textContent = 'Desktop not connected'; + statusDiv.className = 'status disconnected'; + submitBtn.disabled = true; + } +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorDiv.style.display = 'none'; + + const password = passwordInput.value; + + try { + // Test password with a simple API call + const response = await fetch(`/${sessionId}/api/tabs`, { + headers: { + 'X-MacroPad-Password': password + } + }); + + if (response.ok) { + // Hand the credential to the app via storage (never the URL). + sessionStorage.setItem(`macropad_${sessionId}`, password); + if (rememberCheckbox.checked) { + localStorage.setItem(`macropad_${sessionId}`, password); + } else { + localStorage.removeItem(`macropad_${sessionId}`); + } + + // Redirect to the PWA (credential is read from storage). + window.location.href = `/${sessionId}/app`; + } else { + const data = await response.json(); + errorDiv.textContent = data.error || 'Invalid password'; + errorDiv.style.display = 'block'; + } + } catch (error) { + errorDiv.textContent = 'Connection failed'; + errorDiv.style.display = 'block'; + } +}); + +checkStatus(); diff --git a/macropad-relay/src/handlers/desktopHandler.ts b/macropad-relay/src/handlers/desktopHandler.ts index b6a4c7c..f7802fa 100644 --- a/macropad-relay/src/handlers/desktopHandler.ts +++ b/macropad-relay/src/handlers/desktopHandler.ts @@ -37,7 +37,8 @@ type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | Po export function handleDesktopConnection( socket: WebSocket, connectionManager: ConnectionManager, - sessionManager: SessionManager + sessionManager: SessionManager, + clientIp: string ): void { let authenticatedSessionId: string | null = null; // Track failed auth attempts on this individual socket. @@ -49,7 +50,7 @@ export function handleDesktopConnection( switch (message.type) { case 'auth': { - const failed = await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => { + const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => { authenticatedSessionId = sessionId; }); if (failed) { @@ -110,6 +111,7 @@ async function handleAuth( message: AuthMessage, sessionManager: SessionManager, connectionManager: ConnectionManager, + clientIp: string, setSessionId: (id: string) => void ): Promise { try { @@ -117,8 +119,14 @@ async function handleAuth( let session; if (sessionId) { - // Reject early if this session id is currently locked out. - const lockRemaining = getLockoutRemaining(sessionId); + // Namespace the brute-force lockout key to the desktop keyspace and + // include the client IP. This keeps the desktop's lockout state entirely + // separate from the web-client keyspace, so a web-auth flood can never + // lock the desktop out of (re)authenticating. + const throttleKey = `desktop:${sessionId}:${clientIp}`; + + // Reject early if this key is currently locked out. + const lockRemaining = getLockoutRemaining(throttleKey); if (lockRemaining > 0) { socket.send(JSON.stringify({ type: 'auth_response', @@ -132,7 +140,7 @@ async function handleAuth( // Validate existing session const valid = await sessionManager.validatePassword(sessionId, message.password); if (!valid) { - recordFailure(sessionId); + recordFailure(throttleKey); socket.send(JSON.stringify({ type: 'auth_response', success: false, @@ -140,7 +148,7 @@ async function handleAuth( })); return true; } - recordSuccess(sessionId); + recordSuccess(throttleKey); session = sessionManager.getSession(sessionId); } else { // Create new session (may throw on limit / short password) diff --git a/macropad-relay/src/handlers/webClientHandler.ts b/macropad-relay/src/handlers/webClientHandler.ts index aa1c386..fd94bb5 100644 --- a/macropad-relay/src/handlers/webClientHandler.ts +++ b/macropad-relay/src/handlers/webClientHandler.ts @@ -25,8 +25,13 @@ export function handleWebClientConnection( socket: WebSocket, sessionId: string, connectionManager: ConnectionManager, - sessionManager: SessionManager + sessionManager: SessionManager, + clientIp: string ): void { + // Namespace the brute-force lockout key to the web keyspace and include the + // client IP. This prevents one abusive IP (which knows the shareable session + // id) from locking out other web clients or the desktop for this session. + const throttleKey = `web:${sessionId}:${clientIp}`; // Check if session exists const session = sessionManager.getSession(sessionId); if (!session) { @@ -62,7 +67,7 @@ export function handleWebClientConnection( switch (message.type) { case 'auth': { - const failed = await handleAuth(socket, client, message, sessionId, sessionManager); + const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey); if (failed) { socketAuthFailures++; if (socketAuthFailures >= config.authMaxSocketFailures) { @@ -106,10 +111,11 @@ async function handleAuth( client: WebClientConnection, message: AuthMessage, sessionId: string, - sessionManager: SessionManager + sessionManager: SessionManager, + throttleKey: string ): Promise { - // Reject early if this session id is currently locked out. - const lockRemaining = getLockoutRemaining(sessionId); + // Reject early if this key is currently locked out. + const lockRemaining = getLockoutRemaining(throttleKey); if (lockRemaining > 0) { socket.send(JSON.stringify({ type: 'auth_response', @@ -123,7 +129,7 @@ async function handleAuth( const valid = await sessionManager.validatePassword(sessionId, message.password); if (valid) { - recordSuccess(sessionId); + recordSuccess(throttleKey); client.authenticated = true; socket.send(JSON.stringify({ type: 'auth_response', @@ -132,7 +138,7 @@ async function handleAuth( logger.debug(`Web client authenticated for session: ${sessionId}`); return false; } else { - recordFailure(sessionId); + recordFailure(throttleKey); socket.send(JSON.stringify({ type: 'auth_response', success: false, diff --git a/macropad-relay/src/server.ts b/macropad-relay/src/server.ts index a8917b5..a576bfb 100644 --- a/macropad-relay/src/server.ts +++ b/macropad-relay/src/server.ts @@ -89,12 +89,15 @@ export function createServer() { // Middleware app.use(helmet({ - // The bundled login/app pages rely on inline